@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/dist/src/framework.js
CHANGED
|
@@ -5,15 +5,17 @@ import { ReferenceFetcher, DEFAULT_FETCH_MAX_BYTES, EAGER_FETCH_TIMEOUT_MS } fro
|
|
|
5
5
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { JsStore } from '@animalabs/chronicle';
|
|
7
7
|
import { MembraneError } from '@animalabs/membrane';
|
|
8
|
-
import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError } from '@animalabs/context-manager';
|
|
8
|
+
import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError, stripUnpairedToolBlocks } from '@animalabs/context-manager';
|
|
9
9
|
import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES } from './tune-out/tools.js';
|
|
10
10
|
import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js';
|
|
11
|
+
import { bodyGroupRun, bodyRangeIn, locateMessageIndex, locateMessageIndices, removeStoredMessage, removeStoredMessages, } from './stored-removal.js';
|
|
11
12
|
import { ProcessQueueImpl } from './queue.js';
|
|
12
|
-
import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, shutdownErrorMessage } from './shutdown.js';
|
|
13
|
+
import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, shutdownErrorMessage, waitForShutdown, ShutdownTimeoutError } from './shutdown.js';
|
|
13
14
|
import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK, REFUSAL_REACTION_BASELINE } from './refusal-reactions.js';
|
|
14
15
|
import { Agent } from './agent.js';
|
|
15
16
|
import { ModuleRegistry, checkedToolResult, isStateExistsError, storableToolResult, thrownResult } from './module-registry.js';
|
|
16
17
|
import { McplServerRegistry } from './mcpl/server-registry.js';
|
|
18
|
+
import { logValue } from './mcpl/log-value.js';
|
|
17
19
|
import { FeatureSetManager } from './mcpl/feature-set-manager.js';
|
|
18
20
|
import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js';
|
|
19
21
|
import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
|
|
@@ -291,6 +293,7 @@ import { CheckpointManager } from './mcpl/checkpoint-manager.js';
|
|
|
291
293
|
import { isToolAllowed } from './mcpl/tool-policy.js';
|
|
292
294
|
import { EventGate, formatShadowWarning } from './gate/event-gate.js';
|
|
293
295
|
import { UsageTracker } from './usage/usage-tracker.js';
|
|
296
|
+
import { stderrTailOf, withStderr } from './mcpl/server-connection.js';
|
|
294
297
|
import { isWebSocketTransport } from './mcpl/transport.js';
|
|
295
298
|
import { formatZonedDateTime, resolveTimeZone } from './timezone.js';
|
|
296
299
|
import { DEFAULT_DISCORD_AWARENESS_EMOJI, DiscordAwarenessOutbox, defaultDiscordAwarenessOutboxPath, extractDiscordAwarenessRefs, } from './recovery/discord-awareness-outbox.js';
|
|
@@ -338,16 +341,20 @@ function splitExplicitEnvelopes(rawText) {
|
|
|
338
341
|
return envelopes.filter((envelope) => envelope.trim()).map((envelope) => envelope.replace(/^\s+(?=>>)/, ''));
|
|
339
342
|
}
|
|
340
343
|
/**
|
|
341
|
-
* Whether a stream resumes its
|
|
342
|
-
* restart, or a tool-result-guard retry. Such a
|
|
343
|
-
*
|
|
344
|
-
*
|
|
344
|
+
* Whether a stream resumes its turn's request rather than starting one: a
|
|
345
|
+
* budget restart, or a tool-result-guard retry. Such a start takes in no new
|
|
346
|
+
* input, and keeps the turn's locus, trigger channel and tool-call count. Its
|
|
347
|
+
* routing state is the logical turn's, as for every start that continues the
|
|
348
|
+
* turn (TurnRouting).
|
|
345
349
|
*/
|
|
346
350
|
function resumesLogicalTurn(reason) {
|
|
347
351
|
return reason === 'context_budget_restart' || reason === 'tool_result_guard_retry';
|
|
348
352
|
}
|
|
349
353
|
function freshTurnRouting() {
|
|
350
|
-
return {
|
|
354
|
+
return {
|
|
355
|
+
engaged: new Set(), destination: null, hybridHold: false,
|
|
356
|
+
continuation: false, deliveries: [], suppressed: 0, arrivals: [],
|
|
357
|
+
};
|
|
351
358
|
}
|
|
352
359
|
function isTurnContinuation(reason) {
|
|
353
360
|
return reason === 'context_budget_restart' || reason === 'tool_results_ready'
|
|
@@ -383,6 +390,110 @@ function normalizeDiscordAwarenessDeadline(value) {
|
|
|
383
390
|
}
|
|
384
391
|
return Math.max(MIN_DISCORD_AWARENESS_DEADLINE_MS, Math.min(MAX_DISCORD_AWARENESS_DEADLINE_MS, Math.floor(value)));
|
|
385
392
|
}
|
|
393
|
+
/** Messages copied into another conversation (a fork's seed) without their
|
|
394
|
+
* `thinking` and `redacted_thinking` blocks: a fork inherits the template's
|
|
395
|
+
* conversation, not its reasoning. A signed block was minted under the
|
|
396
|
+
* template's prefix, which the fork's doesn't promise to match; unsigned
|
|
397
|
+
* reasoning (text-only providers, XML mode) goes by the same rule. On the
|
|
398
|
+
* OpenAI Responses path a removed block also carried the reasoning item
|
|
399
|
+
* (`rawItem`) the message's other items were paired with by id, so in a
|
|
400
|
+
* message that lost its thinking those items lose their `rawItem` and render
|
|
401
|
+
* from their fields, without ids; an opaque carrier (a text block whose raw
|
|
402
|
+
* item isn't a `message`, such as a compaction) keeps it, since the raw item
|
|
403
|
+
* is its whole content. An Anthropic or XML-mode message's thinking carries
|
|
404
|
+
* no raw item, and its only raw items are such carriers (membrane keeps a
|
|
405
|
+
* block it has no type for, such as a `server_tool_use`, that way), so it
|
|
406
|
+
* loses only its thinking. A message with no content left, whether
|
|
407
|
+
* the filter emptied it or it was already empty, is left out, since a
|
|
408
|
+
* request can't carry an empty message; any other message without thinking
|
|
409
|
+
* is kept as it was. */
|
|
410
|
+
function withoutThinking(messages) {
|
|
411
|
+
const out = [];
|
|
412
|
+
for (const message of messages) {
|
|
413
|
+
const content = message.content.filter((b) => !isThinkingBlock(b));
|
|
414
|
+
if (content.length === 0)
|
|
415
|
+
continue;
|
|
416
|
+
out.push(content.length === message.content.length
|
|
417
|
+
? message
|
|
418
|
+
: { ...message, content: content.map(releasePairedRawItem) });
|
|
419
|
+
}
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
function isThinkingBlock(block) {
|
|
423
|
+
return block.type === 'thinking' || block.type === 'redacted_thinking';
|
|
424
|
+
}
|
|
425
|
+
function rawItemOf(block) {
|
|
426
|
+
return block.rawItem;
|
|
427
|
+
}
|
|
428
|
+
/** A block without the provider-native item it was parsed from, unless that
|
|
429
|
+
* item is the block's whole content: an opaque carrier, a text block whose
|
|
430
|
+
* raw item isn't a `message` item (membrane keeps compaction and other items
|
|
431
|
+
* it has no block for that way). */
|
|
432
|
+
function releasePairedRawItem(block) {
|
|
433
|
+
const raw = rawItemOf(block);
|
|
434
|
+
if (raw === undefined)
|
|
435
|
+
return block;
|
|
436
|
+
const rawType = typeof raw === 'object' && raw !== null ? raw.type : undefined;
|
|
437
|
+
if (block.type === 'text' && rawType !== 'message')
|
|
438
|
+
return block;
|
|
439
|
+
const { rawItem: _released, ...rest } = block;
|
|
440
|
+
return rest;
|
|
441
|
+
}
|
|
442
|
+
/** One log line for a fork's seed that left something out, so an operator who
|
|
443
|
+
* sees a fork answer differently from its template has something to check:
|
|
444
|
+
* the template's thinking blocks, and the tool blocks the pairing pass
|
|
445
|
+
* dropped (calls with no result in the seed, results whose call isn't in
|
|
446
|
+
* it). `withoutReasoning` is the seed before that pass, `seed` after it. */
|
|
447
|
+
function logSeedOmissions(fork, template, compiled, withoutReasoning, seed) {
|
|
448
|
+
const count = (messages, test) => messages.reduce((n, m) => n + m.content.filter(test).length, 0);
|
|
449
|
+
const thinking = count(compiled, isThinkingBlock);
|
|
450
|
+
const calls = count(withoutReasoning, (b) => b.type === 'tool_use') - count(seed, (b) => b.type === 'tool_use');
|
|
451
|
+
const results = count(withoutReasoning, (b) => b.type === 'tool_result') - count(seed, (b) => b.type === 'tool_result');
|
|
452
|
+
if (thinking + calls + results === 0)
|
|
453
|
+
return;
|
|
454
|
+
console.log(`[conversation] ${fork}: seeded from ${template} without ${thinking} thinking block(s), ` +
|
|
455
|
+
`${calls} unanswered tool call(s) and ${results} orphaned tool result(s)`);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* A maintenance provider call withdrawn because a surgery holds the store, or
|
|
459
|
+
* because stop() has closed provider admission: either is the framework's own
|
|
460
|
+
* doing, and no failure. It sent nothing: it was refused at the provider
|
|
461
|
+
* gate, or woken there from its wait. For a call outside context-manager
|
|
462
|
+
* 0.14's compression fallback ladder, a merge's or a chunk's canonical call
|
|
463
|
+
* with its retries after a tool_use stop, context-manager counts no attempt.
|
|
464
|
+
* A merge keeps its queue entry, so the next tick after the hold, or after
|
|
465
|
+
* the next start, does it again. A chunk's compression has already left the
|
|
466
|
+
* queue: it logs "Failed to compress chunk (hierarchical)" with this error
|
|
467
|
+
* and records the call, and the chunk, still uncompressed, is queued again at
|
|
468
|
+
* that manager's next rebuild (its next initialization, message, sent compile
|
|
469
|
+
* or released hold). A call inside the ladder, after a chunk's canonical call
|
|
470
|
+
* was refused or cut short, is caught by the rung that made it and recorded
|
|
471
|
+
* as a provider error, since 0.14 rethrows only its own
|
|
472
|
+
* CompressionBranchDiscard there; once every rung has failed, the chunk's
|
|
473
|
+
* request family is quarantined, with its alert, and the tick returns.
|
|
474
|
+
*/
|
|
475
|
+
class MaintenanceWithdrawnError extends Error {
|
|
476
|
+
constructor(reason) {
|
|
477
|
+
super(`maintenance call withdrawn: ${reason}`);
|
|
478
|
+
this.name = 'MaintenanceWithdrawnError';
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* A runtime-settings preview's reason, from what stopped it: the strategy's
|
|
483
|
+
* known throws as the codes `RuntimeSettingsPreview.reason` documents, and
|
|
484
|
+
* anything else verbatim. Message matching is fragile by nature, so an
|
|
485
|
+
* unknown message surfaces as the reason rather than being swallowed.
|
|
486
|
+
*/
|
|
487
|
+
function previewStoppedReason(error) {
|
|
488
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
489
|
+
if (message.includes('requires reinitialization for the current branch generation'))
|
|
490
|
+
return 'branch_generation_changed';
|
|
491
|
+
if (message.includes('already running; previews must not overlap'))
|
|
492
|
+
return 'preview_in_flight';
|
|
493
|
+
if (message.includes('requires adaptiveResolution'))
|
|
494
|
+
return 'no_adaptive_resolution';
|
|
495
|
+
return message;
|
|
496
|
+
}
|
|
386
497
|
class DiscordAwarenessAccountingError extends Error {
|
|
387
498
|
constructor(operation, cause) {
|
|
388
499
|
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
@@ -422,58 +533,32 @@ export class ResumeBlockedError extends Error {
|
|
|
422
533
|
}
|
|
423
534
|
}
|
|
424
535
|
/**
|
|
425
|
-
*
|
|
536
|
+
* How `runEphemeralToCompletion` rejects when a retryable provider failure
|
|
537
|
+
* states a wait the run won't sleep: one longer than an ephemeral run sleeps
|
|
538
|
+
* in its turn (600 s), an infinite one, or one that is `NaN`. The run ends at
|
|
539
|
+
* once, without retrying inside the wait, and every other ending of the run
|
|
540
|
+
* rejects with a plain `Error`. `retryAfterMs` is the wait as the provider
|
|
541
|
+
* stated it, in milliseconds, so `Infinity` or `NaN` in those two cases: a
|
|
542
|
+
* host that schedules a rerun from it handles a value that isn't finite, and
|
|
543
|
+
* one past the 2,147,483,647 ms a timer can hold. The message says the same
|
|
544
|
+
* in words. A host can test `instanceof` or the `name`, which also holds for
|
|
545
|
+
* an error from another copy of this package.
|
|
426
546
|
*/
|
|
427
|
-
class
|
|
428
|
-
|
|
429
|
-
|
|
547
|
+
export class ProviderWaitNotSleptError extends Error {
|
|
548
|
+
retryAfterMs;
|
|
549
|
+
constructor(message, retryAfterMs) {
|
|
550
|
+
super(message);
|
|
551
|
+
this.retryAfterMs = retryAfterMs;
|
|
552
|
+
this.name = 'ProviderWaitNotSleptError';
|
|
430
553
|
}
|
|
431
554
|
}
|
|
432
|
-
const SURGERY_SCAN_WINDOW = 500;
|
|
433
555
|
/**
|
|
434
|
-
*
|
|
435
|
-
* in blob-free windows: operator targets are almost always recent, and a
|
|
436
|
-
* full `getAllMessages()` re-inflates every attachment on the branch.
|
|
556
|
+
* Default inference policy - infer if any request exists for the agent.
|
|
437
557
|
*/
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
}
|
|
442
|
-
/** Tail-first scan resolving many ids at once; stops as soon as all are found. */
|
|
443
|
-
function locateMessageIndices(cm, ids) {
|
|
444
|
-
const found = new Map();
|
|
445
|
-
const total = cm.getMessageCount();
|
|
446
|
-
for (let end = total; end > 0 && found.size < ids.size; end -= SURGERY_SCAN_WINDOW) {
|
|
447
|
-
const start = Math.max(0, end - SURGERY_SCAN_WINDOW);
|
|
448
|
-
const { messages, startIndex } = cm.getMessageWindow(start, end - start, { resolveBlobs: false });
|
|
449
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
450
|
-
const id = String(messages[i].id);
|
|
451
|
-
if (ids.has(id) && !found.has(id)) {
|
|
452
|
-
found.set(id, {
|
|
453
|
-
index: startIndex + i,
|
|
454
|
-
...(messages[i].bodyGroupId ? { bodyGroupId: messages[i].bodyGroupId } : {}),
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
}
|
|
558
|
+
class DefaultInferencePolicy {
|
|
559
|
+
shouldInfer(agentName, requests, _state) {
|
|
560
|
+
return requests.some((r) => r.agentName === agentName);
|
|
458
561
|
}
|
|
459
|
-
return found;
|
|
460
|
-
}
|
|
461
|
-
/**
|
|
462
|
-
* Inclusive slot range of the body-group run containing `index` (the slot
|
|
463
|
-
* itself when it is not sharded). Delegates to the store's own
|
|
464
|
-
* `alignToBodyGroups` edge walk — one contiguity assumption, not two.
|
|
465
|
-
*/
|
|
466
|
-
function bodyGroupRun(cm, index) {
|
|
467
|
-
const win = cm.getMessageWindow(index, 1, { alignToBodyGroups: true, resolveBlobs: false });
|
|
468
|
-
const from = win.startIndex;
|
|
469
|
-
const to = win.startIndex + win.messages.length - 1;
|
|
470
|
-
return {
|
|
471
|
-
from,
|
|
472
|
-
to,
|
|
473
|
-
fromId: String(win.messages[0].id),
|
|
474
|
-
toId: String(win.messages[win.messages.length - 1].id),
|
|
475
|
-
group: win.messages[0].bodyGroupId !== undefined,
|
|
476
|
-
};
|
|
477
562
|
}
|
|
478
563
|
function describeMarkers(m) {
|
|
479
564
|
if (m.status === 'queued')
|
|
@@ -834,18 +919,19 @@ function boundFailureFields(record) {
|
|
|
834
919
|
*
|
|
835
920
|
* 400 and 422 are how a provider answers a request that fails its
|
|
836
921
|
* validation, as a history it can't accept does, but other refusals come
|
|
837
|
-
* back the same way, such as
|
|
838
|
-
*
|
|
839
|
-
* large, as an oversized attachment does, shedding that
|
|
840
|
-
* the request back under the limit, and nothing else in
|
|
841
|
-
* recovers a 413; when the history as a whole is too large,
|
|
842
|
-
* help, and the breaker stops at its rewind cap. The 413 arm
|
|
843
|
-
* labels a 413
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
847
|
-
*
|
|
848
|
-
* resource, as a rule,
|
|
922
|
+
* back the same way, such as a refusal of the account that membrane doesn't
|
|
923
|
+
* recognize by its text. A 413 is the request's size. When the newest
|
|
924
|
+
* exchange made it too large, as an oversized attachment does, shedding that
|
|
925
|
+
* exchange may bring the request back under the limit, and nothing else in
|
|
926
|
+
* the framework recovers a 413; when the history as a whole is too large,
|
|
927
|
+
* the shed doesn't help, and the breaker stops at its rewind cap. The 413 arm
|
|
928
|
+
* takes both labels membranes have given a 413, invalid_request and
|
|
929
|
+
* context_length (see the notes below). Other statuses keep the history,
|
|
930
|
+
* since a shed takes the exchange out of the agent's context, which only an
|
|
931
|
+
* operator's undo brings back, and the status gives no sign that a shed
|
|
932
|
+
* would help: a 404 is a model or an endpoint that doesn't exist, and 405,
|
|
933
|
+
* 409, 410 and 415 are about the HTTP request or the resource, as a rule,
|
|
934
|
+
* rather than the conversation.
|
|
849
935
|
*
|
|
850
936
|
* The table test in test/inference-exhaustion-rewind.test.ts is this
|
|
851
937
|
* predicate's contract. The notes below on which membrane version gives which
|
|
@@ -860,13 +946,24 @@ function boundFailureFields(record) {
|
|
|
860
946
|
* adapter files every status it doesn't list (404, 405, 409, 410, 413,
|
|
861
947
|
* 415, 422 …) as invalid_request, so it alone produces an invalid_request
|
|
862
948
|
* at 413 or 422, and no 0.5.86 adapter produces a context_length at 413.
|
|
863
|
-
* - From membrane
|
|
864
|
-
* adapter
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
*
|
|
949
|
+
* - From membrane 0.5.87 (membrane #56 and #107), this package's floor, every
|
|
950
|
+
* adapter sends an HTTP failure through one status table
|
|
951
|
+
* (errorFromProviderStatus). On a 400 or 422, auth codes and reasons and
|
|
952
|
+
* the two account refusals it recognizes by their text (a balance too low,
|
|
953
|
+
* usage limits reached) are auth, and a context overflow, by its code or
|
|
954
|
+
* text, is context_length. A 401, 402 or 403 is auth, a 408 timeout, a 429
|
|
955
|
+
* rate_limit, a 404 invalid_request and a 413 context_length; other 4xx
|
|
956
|
+
* are invalid_request. So a 413 the table types sheds, an Anthropic one
|
|
957
|
+
* included, while a context_length at 400 or 422, a token overflow, keeps
|
|
958
|
+
* the history, and so does an account refusal, which is auth. An adapter
|
|
959
|
+
* can type a failure before the table does, as Anthropic's does for a
|
|
960
|
+
* gateway's aggregate error (server). A failure with no HTTP status takes
|
|
961
|
+
* the status its provider code implies when the table knows the code, as
|
|
962
|
+
* for Anthropic's mid-stream errors, and is otherwise typed from its
|
|
963
|
+
* message, with no status. The 422 arm also covers an
|
|
964
|
+
* OpenAI-compatible server, such as a FastAPI one, that answers a
|
|
965
|
+
* validation failure with 422. The test of provider answers through the
|
|
966
|
+
* table, beside the table test, pins how the two meet at the floor.
|
|
870
967
|
*
|
|
871
968
|
* An error with no status, from a membrane that reports none, keeps the rule
|
|
872
969
|
* the breaker had before: the type alone.
|
|
@@ -960,6 +1057,10 @@ export class AgentFramework {
|
|
|
960
1057
|
stopPromise = null;
|
|
961
1058
|
stopCompleted = false;
|
|
962
1059
|
ownedStoreClosed = false;
|
|
1060
|
+
/** An operator's operations in flight, and the config materializations they
|
|
1061
|
+
* start. stop() waits until this set is empty before it tears down, unless
|
|
1062
|
+
* its wait budget runs out. */
|
|
1063
|
+
operatorOperations = new Set();
|
|
963
1064
|
membrane;
|
|
964
1065
|
queue;
|
|
965
1066
|
agents = new Map();
|
|
@@ -967,16 +1068,6 @@ export class AgentFramework {
|
|
|
967
1068
|
inferencePolicy;
|
|
968
1069
|
errorPolicy;
|
|
969
1070
|
pendingRequests = [];
|
|
970
|
-
/**
|
|
971
|
-
* Per-agent channel that triggered the agent's CURRENT inference turn, if any
|
|
972
|
-
* (item-3 redux). Read by the ChannelRegistry's `activeChannelResolver` to
|
|
973
|
-
* route a single-trunk agent's plain-text speech back to the channel it is
|
|
974
|
-
* answering, instead of the process-global most-recent-inbound locus that a
|
|
975
|
-
* concurrent message elsewhere can hijack. Set at turn start (or cleared for a
|
|
976
|
-
* heartbeat / no-trigger turn) in startAgentStream; overwritten by the next
|
|
977
|
-
* turn. Never read between turns (a given agent runs one turn at a time).
|
|
978
|
-
*/
|
|
979
|
-
activeTriggerChannels = new Map();
|
|
980
1071
|
running = false;
|
|
981
1072
|
loopPromise = null;
|
|
982
1073
|
/** Quiesce/maintenance mode (issue #122). DELIBERATELY separate from
|
|
@@ -999,6 +1090,14 @@ export class AgentFramework {
|
|
|
999
1090
|
maintenanceRunId = 0;
|
|
1000
1091
|
currentMaintenanceRun = null;
|
|
1001
1092
|
maintenanceHistory = [];
|
|
1093
|
+
/** Each agent's last reported maintenance failure, its whole text and when
|
|
1094
|
+
* it was alerted, until its maintenance goes without one
|
|
1095
|
+
* (reportMaintenanceFailure). */
|
|
1096
|
+
maintenanceFailureReported = new Map();
|
|
1097
|
+
/** Run records whose failure repeated one already reported, and whose report was suppressed. */
|
|
1098
|
+
maintenanceRepeats = new WeakSet();
|
|
1099
|
+
/** 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. */
|
|
1100
|
+
maintenancePassRequested = false;
|
|
1002
1101
|
providerGates = new Map();
|
|
1003
1102
|
providerAccelerationCooldowns = new Map();
|
|
1004
1103
|
providerAccelerationRecoveries = new Map();
|
|
@@ -1020,32 +1119,6 @@ export class AgentFramework {
|
|
|
1020
1119
|
processLoggingPersist;
|
|
1021
1120
|
processLoggingBroadcast;
|
|
1022
1121
|
activeStreams = new Map();
|
|
1023
|
-
/** Per-agent output locus FROZEN for the CURRENT logical turn. Resolved
|
|
1024
|
-
* eagerly in startAgentStream (home → addressed trigger → global default)
|
|
1025
|
-
* and never moved until the next turn: mid-turn injected messages — ambient
|
|
1026
|
-
* chatter, reactions, system markers — must not hijack where the agent's
|
|
1027
|
-
* plain prose lands (2026-07-21 Cairn lounge misroute). Lives here — not in
|
|
1028
|
-
* driveStream locals — so the pin survives a context-budget stream restart,
|
|
1029
|
-
* which continues the same logical turn in a fresh driveStream. Cleared or
|
|
1030
|
-
* re-set at the start of every non-restart turn. Since 2026-07-31 the pin
|
|
1031
|
-
* is no longer fully frozen: an ADDRESSED conversational injection — or a
|
|
1032
|
-
* conversational follow-up in a channel the agent itself explicitly sent
|
|
1033
|
-
* into this turn (turnEngagedChannels) — re-pins it at the boundary (see
|
|
1034
|
-
* the addressed re-pin block in the tool-result handler). Ambient chatter
|
|
1035
|
-
* elsewhere, reactions, and system markers still cannot move it. */
|
|
1036
|
-
turnLocusPins = new Map();
|
|
1037
|
-
/** Channels the agent EXPLICITLY sent into during the CURRENT turn
|
|
1038
|
-
* (SEND_ENGAGEMENT_TOOLS, successful calls), per agent. Second re-pin
|
|
1039
|
-
* signal: a conversational reply in a channel the agent just engaged is
|
|
1040
|
-
* addressed by context even without a mention (2026-07-31 n=7: q's
|
|
1041
|
-
* #portables follow-up — no @mention — was answered in trailing prose
|
|
1042
|
-
* that followed the stale pin into repligate's DM). No author-kind
|
|
1043
|
-
* filter: agent-residents are full participants, and the bot flag
|
|
1044
|
-
* tracks nothing that matters (antra). Reactions/system markers are
|
|
1045
|
-
* excluded by isConversationalInjection; the engaged-this-turn scope
|
|
1046
|
-
* keeps unrelated channels from moving the pin. Cleared at every fresh
|
|
1047
|
-
* turn's start; budget restarts keep it. */
|
|
1048
|
-
turnEngagedChannels = new Map();
|
|
1049
1122
|
/** A tool boundary injected fresh CONVERSATIONAL input (a real message —
|
|
1050
1123
|
* not a reaction or a system marker) into the live stream. Tells
|
|
1051
1124
|
* driveStream to clear sticky explicit-send suppression before handling
|
|
@@ -1060,12 +1133,13 @@ export class AgentFramework {
|
|
|
1060
1133
|
/** Latest bounced (undelivered) prose per agent — the `{{unsent}}` source. */
|
|
1061
1134
|
proseClipboards = new Map();
|
|
1062
1135
|
/**
|
|
1063
|
-
* Each agent's routing state for its current turn (TurnRouting):
|
|
1064
|
-
*
|
|
1065
|
-
* hybrid's hold, a
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
1068
|
-
* turn
|
|
1136
|
+
* Each agent's routing state for its current logical turn (TurnRouting):
|
|
1137
|
+
* its trigger channel, locus and engaged channels, the sticky destination,
|
|
1138
|
+
* the turn's destination, hybrid's hold, a ` !` continuation and receipt
|
|
1139
|
+
* tallies. The start that opens a turn makes a
|
|
1140
|
+
* new object instead of clearing the old one, and every later stream of
|
|
1141
|
+
* the turn shares it (openLogical). A queued delivery holds the object of
|
|
1142
|
+
* the turn that queued it, so prose a turn leaves queued when it ends (a
|
|
1069
1143
|
* skip_reply ends it before its posts have run) still follows that turn's
|
|
1070
1144
|
* destination, and its late writes can't reach the next turn.
|
|
1071
1145
|
*/
|
|
@@ -1077,23 +1151,25 @@ export class AgentFramework {
|
|
|
1077
1151
|
*/
|
|
1078
1152
|
proseSpeechChains = new Map();
|
|
1079
1153
|
/**
|
|
1080
|
-
* Each agent's open logical turn
|
|
1081
|
-
*
|
|
1082
|
-
* completes, a tool ends it, or it fails or
|
|
1083
|
-
* start that routes prose while it is open
|
|
1084
|
-
*
|
|
1085
|
-
*
|
|
1086
|
-
*
|
|
1087
|
-
*
|
|
1088
|
-
*
|
|
1089
|
-
*
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
1092
|
-
*
|
|
1093
|
-
* a
|
|
1094
|
-
*
|
|
1095
|
-
* start
|
|
1096
|
-
*
|
|
1154
|
+
* Each agent's open logical turn: its routing state and receipt tallies
|
|
1155
|
+
* (TurnRouting). It stays open from the start that opens it until the
|
|
1156
|
+
* turn settles (settleAgent: it completes, a tool ends it, or it fails or
|
|
1157
|
+
* is abandoned), and every start that routes prose while it is open
|
|
1158
|
+
* continues it with that object: a framework retry, the start that
|
|
1159
|
+
* consumes tool results the non-streaming fallback left (whichever wake
|
|
1160
|
+
* the scheduler starts it with), the later compile a provider-capacity
|
|
1161
|
+
* hold defers a turn to. A silent start neither continues nor opens one,
|
|
1162
|
+
* and leaves the open turn's object untouched (its own is the live routing
|
|
1163
|
+
* while it runs: turnRouting). A stream that ends without
|
|
1164
|
+
* settling needs to do nothing to carry its turn. Settling only detaches
|
|
1165
|
+
* the turn from future starts: the settling stream keeps its own object,
|
|
1166
|
+
* which its remaining deliveries, its receipt and its ` !` check go on
|
|
1167
|
+
* using (a completion settles before it awaits its chain and writes its
|
|
1168
|
+
* receipt). So every logical end must close inheritance once, before the
|
|
1169
|
+
* next fresh start: a turn left open would hand its object, late writes
|
|
1170
|
+
* and all, to an unrelated turn. A tool-ended turn settles before the
|
|
1171
|
+
* next turn can start. Only the stream that holds the open turn closes
|
|
1172
|
+
* it; disposal drops it, and stop() clears every agent's.
|
|
1097
1173
|
*/
|
|
1098
1174
|
openLogical = new Map();
|
|
1099
1175
|
/** Consecutive bounce-wakes per agent; capped so an agent that keeps
|
|
@@ -1156,6 +1232,8 @@ export class AgentFramework {
|
|
|
1156
1232
|
* tick counts in the success log stop meaning anything. One kick per agent
|
|
1157
1233
|
* at a time; cleared when the kick settles (success or failure). */
|
|
1158
1234
|
overBudgetDrainInFlight = new Set();
|
|
1235
|
+
/** The over-budget drain kicks running, which a branch move waits for as it waits for a maintenance pass. */
|
|
1236
|
+
overBudgetDrains = new Set();
|
|
1159
1237
|
/** Per-agent current rewind episode: the single consolidated marker's id and
|
|
1160
1238
|
* how many turns have been shed so far. One marker per episode, updated in
|
|
1161
1239
|
* place (see updateRewindMarker); cleared when the episode ends. */
|
|
@@ -1854,11 +1932,16 @@ export class AgentFramework {
|
|
|
1854
1932
|
}
|
|
1855
1933
|
}
|
|
1856
1934
|
/**
|
|
1857
|
-
* Stop the event loop.
|
|
1858
|
-
*
|
|
1859
|
-
*
|
|
1860
|
-
*
|
|
1861
|
-
*
|
|
1935
|
+
* Stop the event loop. After a failed or timed-out wait on operator, module
|
|
1936
|
+
* or MCPL cleanup, storage stays open and stop() can be called again. A
|
|
1937
|
+
* timed-out wait lets teardown go on while that work may still act on what
|
|
1938
|
+
* is being torn down.
|
|
1939
|
+
* @param teardownTimeoutMs Optional budget for each wait on cleanup: an
|
|
1940
|
+
* operator's operations already running, then modules and MCPL together.
|
|
1941
|
+
* Omit to wait until cleanup settles, as required by existing module/child
|
|
1942
|
+
* contracts. This does not bound earlier stream, event-loop or maintenance
|
|
1943
|
+
* quiescence (a maintenance pass or over-budget drain kick in flight), or
|
|
1944
|
+
* cancel cleanup.
|
|
1862
1945
|
*/
|
|
1863
1946
|
stop(teardownTimeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS) {
|
|
1864
1947
|
try {
|
|
@@ -1869,7 +1952,12 @@ export class AgentFramework {
|
|
|
1869
1952
|
}
|
|
1870
1953
|
if (this.stopPromise)
|
|
1871
1954
|
return this.stopPromise;
|
|
1872
|
-
|
|
1955
|
+
// Claim the attempt before any of its body runs: stopOwned's synchronous
|
|
1956
|
+
// start can emit traces (the push coalescer's suspend() does), and a
|
|
1957
|
+
// listener that calls stop() there must get this attempt, not start a
|
|
1958
|
+
// second teardown beside it.
|
|
1959
|
+
let settle;
|
|
1960
|
+
const attempt = new Promise((resolve, reject) => { settle = { resolve, reject }; });
|
|
1873
1961
|
this.stopPromise = attempt;
|
|
1874
1962
|
void attempt.then(() => { this.stopCompleted = true; }, () => { });
|
|
1875
1963
|
// Failed cleanup can be retried while its original store is open. A
|
|
@@ -1878,6 +1966,9 @@ export class AgentFramework {
|
|
|
1878
1966
|
if (!this.ownedStoreClosed && this.stopPromise === attempt)
|
|
1879
1967
|
this.stopPromise = null;
|
|
1880
1968
|
});
|
|
1969
|
+
// The body still starts in this call, so its synchronous steps (closing
|
|
1970
|
+
// the queue among them) happen before stop() returns.
|
|
1971
|
+
this.stopOwned(teardownTimeoutMs).then(settle.resolve, settle.reject);
|
|
1881
1972
|
return attempt;
|
|
1882
1973
|
}
|
|
1883
1974
|
resetCompletedStop() {
|
|
@@ -1888,6 +1979,59 @@ export class AgentFramework {
|
|
|
1888
1979
|
this.stopCompleted = false;
|
|
1889
1980
|
}
|
|
1890
1981
|
}
|
|
1982
|
+
/** Register before invoking the body, so the set holds the operation for
|
|
1983
|
+
* its whole life: its synchronous trace callbacks can reenter stop(), and
|
|
1984
|
+
* that stop() must find it. Invocation stays in this stack, preserving
|
|
1985
|
+
* immediate mode changes; the returned promise carries its result to the
|
|
1986
|
+
* caller. */
|
|
1987
|
+
trackOperatorOperation(label, run) {
|
|
1988
|
+
let resolve;
|
|
1989
|
+
let reject;
|
|
1990
|
+
const operation = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
1991
|
+
const entry = { label, promise: operation.then(() => { }, () => { }) };
|
|
1992
|
+
this.operatorOperations.add(entry);
|
|
1993
|
+
void entry.promise.then(() => { this.operatorOperations.delete(entry); });
|
|
1994
|
+
try {
|
|
1995
|
+
resolve(run());
|
|
1996
|
+
}
|
|
1997
|
+
catch (error) {
|
|
1998
|
+
reject(error);
|
|
1999
|
+
}
|
|
2000
|
+
return operation;
|
|
2001
|
+
}
|
|
2002
|
+
/** Whether stop() has begun: an attempt in flight (stopPromise is set before
|
|
2003
|
+
* its body runs, which emits traces before the queue closes) or a closed
|
|
2004
|
+
* queue (after a failed or completed stop()). The admission rule for every
|
|
2005
|
+
* operator entry point, the host/command listener included. */
|
|
2006
|
+
stopHasBegun() {
|
|
2007
|
+
return !!this.stopPromise || !!this.queue?.isClosed;
|
|
2008
|
+
}
|
|
2009
|
+
refuseOperatorAfterStop(verb) {
|
|
2010
|
+
if (this.stopHasBegun())
|
|
2011
|
+
throw new Error(verb + ' refused: the framework is stopping');
|
|
2012
|
+
}
|
|
2013
|
+
/** Wait in rounds until the set is empty: admitted work can still start work
|
|
2014
|
+
* of its own. All rounds share the caller's deadline rather than renewing
|
|
2015
|
+
* its budget. */
|
|
2016
|
+
async waitForOperatorOperations(timeoutMs) {
|
|
2017
|
+
const deadline = timeoutMs === undefined ? undefined : performance.now() + timeoutMs;
|
|
2018
|
+
while (this.operatorOperations.size > 0) {
|
|
2019
|
+
const remaining = deadline === undefined ? undefined : Math.ceil(deadline - performance.now());
|
|
2020
|
+
const timedOut = () => new ShutdownTimeoutError([...this.operatorOperations].map(({ label }) => label), timeoutMs, []);
|
|
2021
|
+
if (remaining !== undefined && remaining < 1)
|
|
2022
|
+
throw timedOut();
|
|
2023
|
+
try {
|
|
2024
|
+
await waitForShutdown([...this.operatorOperations], remaining);
|
|
2025
|
+
}
|
|
2026
|
+
catch (error) {
|
|
2027
|
+
// Report the original budget and everything still pending, including
|
|
2028
|
+
// child work added after this round's snapshot.
|
|
2029
|
+
if (error instanceof ShutdownTimeoutError)
|
|
2030
|
+
throw timedOut();
|
|
2031
|
+
throw error;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
1891
2035
|
async stopOwned(teardownTimeoutMs) {
|
|
1892
2036
|
this.pushCoalescer?.suspend();
|
|
1893
2037
|
this.flushCoalescingSnapshot();
|
|
@@ -1928,8 +2072,10 @@ export class AgentFramework {
|
|
|
1928
2072
|
// A stopped host must never hang behind its own cooldown.
|
|
1929
2073
|
this.cancelProviderAdmission();
|
|
1930
2074
|
// An open logical turn doesn't survive a stop: a start after a restart
|
|
1931
|
-
// on this instance opens a new one rather than
|
|
1932
|
-
//
|
|
2075
|
+
// on this instance opens a new one rather than continuing the old turn's
|
|
2076
|
+
// routing (openLogical). A resume still continues its turn, with its
|
|
2077
|
+
// routing state (turnRouting), since it carries that turn's own request
|
|
2078
|
+
// on: beginAgentTurn opens the turn again for it.
|
|
1933
2079
|
this.openLogical.clear();
|
|
1934
2080
|
// Cancel all active streams
|
|
1935
2081
|
for (const agent of this.agents.values()) {
|
|
@@ -1951,12 +2097,25 @@ export class AgentFramework {
|
|
|
1951
2097
|
clearInterval(this.maintenanceTimer);
|
|
1952
2098
|
this.maintenanceTimer = null;
|
|
1953
2099
|
}
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
2100
|
+
// The pass and any over-budget drain kick: a compression in flight lands
|
|
2101
|
+
// before the store closes.
|
|
2102
|
+
await this.settleMaintenancePass();
|
|
1957
2103
|
if (this.loopPromise) {
|
|
1958
2104
|
await this.loopPromise;
|
|
1959
2105
|
}
|
|
2106
|
+
// Admission closed when stop() began. What admitted operations start now,
|
|
2107
|
+
// such as a surgery's config materialization, joins the set, so wait until
|
|
2108
|
+
// the set is empty. This comes after the streams and the event loop, which
|
|
2109
|
+
// a quiesce or a surgery may be waiting on. If the budget runs out,
|
|
2110
|
+
// teardown goes on while the operation may still act on what it tears
|
|
2111
|
+
// down, and the store stays open for another stop().
|
|
2112
|
+
let operatorOperationsTimedOut;
|
|
2113
|
+
try {
|
|
2114
|
+
await this.waitForOperatorOperations(teardownTimeoutMs);
|
|
2115
|
+
}
|
|
2116
|
+
catch (error) {
|
|
2117
|
+
operatorOperationsTimedOut = error;
|
|
2118
|
+
}
|
|
1960
2119
|
// Stop typing indicators
|
|
1961
2120
|
this.channelRegistry?.stopAll();
|
|
1962
2121
|
// Dispose EventGate (clear debounce timers)
|
|
@@ -1981,7 +2140,7 @@ export class AgentFramework {
|
|
|
1981
2140
|
}
|
|
1982
2141
|
// Timed-out or failed teardown retains its context and may still write.
|
|
1983
2142
|
// Sync available state best-effort, but leave its store open for a retry.
|
|
1984
|
-
const shutdownErrors = [];
|
|
2143
|
+
const shutdownErrors = operatorOperationsTimedOut === undefined ? [] : [operatorOperationsTimedOut];
|
|
1985
2144
|
for (const result of await Promise.allSettled(shutdownPromises)) {
|
|
1986
2145
|
if (result.status === 'rejected')
|
|
1987
2146
|
shutdownErrors.push(result.reason);
|
|
@@ -2082,8 +2241,15 @@ export class AgentFramework {
|
|
|
2082
2241
|
}
|
|
2083
2242
|
async withAuxiliaryAdmission(agentName, run, model) {
|
|
2084
2243
|
const gate = this.providerGate(agentName);
|
|
2244
|
+
// A surgery's hold or a stop withdraws the call before a provider wait
|
|
2245
|
+
// can refuse it: either is the framework's own doing, and no failure.
|
|
2246
|
+
this.withdrawMaintenanceUnderSurgery();
|
|
2247
|
+
this.withdrawMaintenanceWhenStopping(agentName);
|
|
2085
2248
|
this.refuseHeldProviderModel(agentName, model);
|
|
2086
|
-
|
|
2249
|
+
// A parked call is woken when the gate opens, when stop() closes
|
|
2250
|
+
// admission, or when a surgery takes the store; one woken while the gate
|
|
2251
|
+
// is still closed parks again.
|
|
2252
|
+
while (this.providerGateBlocked(agentName)) {
|
|
2087
2253
|
gate.deferredAuxiliary++;
|
|
2088
2254
|
try {
|
|
2089
2255
|
await new Promise((resolve) => gate.auxiliaryWaiters.push(resolve));
|
|
@@ -2091,10 +2257,12 @@ export class AgentFramework {
|
|
|
2091
2257
|
finally {
|
|
2092
2258
|
gate.deferredAuxiliary = Math.max(0, gate.deferredAuxiliary - 1);
|
|
2093
2259
|
}
|
|
2260
|
+
if (this.providerAdmissionClosed)
|
|
2261
|
+
break;
|
|
2262
|
+
// A surgery that took the store while this call was parked withdraws it.
|
|
2263
|
+
this.withdrawMaintenanceUnderSurgery();
|
|
2094
2264
|
}
|
|
2095
|
-
|
|
2096
|
-
throw new Error(`Provider admission closed while stopping (${agentName})`);
|
|
2097
|
-
}
|
|
2265
|
+
this.withdrawMaintenanceWhenStopping(agentName);
|
|
2098
2266
|
// A wait recorded while this call was parked binds it too.
|
|
2099
2267
|
this.refuseHeldProviderModel(agentName, model);
|
|
2100
2268
|
gate.auxiliaryInFlight++;
|
|
@@ -2121,6 +2289,28 @@ export class AgentFramework {
|
|
|
2121
2289
|
resolve();
|
|
2122
2290
|
}
|
|
2123
2291
|
}
|
|
2292
|
+
/**
|
|
2293
|
+
* Maintenance makes no provider call while a surgery holds the store. The
|
|
2294
|
+
* surgery waits for the maintenance work that is running before it moves
|
|
2295
|
+
* the branch, so a call that parked at the gate would make it wait on
|
|
2296
|
+
* whatever reopens the gate: the end of a cooldown, or the retry turn a
|
|
2297
|
+
* cooldown queues, which the hold itself keeps from starting.
|
|
2298
|
+
*/
|
|
2299
|
+
withdrawMaintenanceUnderSurgery() {
|
|
2300
|
+
if (this.surgeryHold) {
|
|
2301
|
+
throw new MaintenanceWithdrawnError(`a live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} holds the store`);
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* Nor once stop() has closed provider admission: a stop that catches a
|
|
2306
|
+
* tick is the framework stopping on purpose, so the call is withdrawn, as
|
|
2307
|
+
* under a surgery's hold, and no failure is reported for it.
|
|
2308
|
+
*/
|
|
2309
|
+
withdrawMaintenanceWhenStopping(agentName) {
|
|
2310
|
+
if (this.providerAdmissionClosed) {
|
|
2311
|
+
throw new MaintenanceWithdrawnError(`provider admission closed while stopping (${agentName})`);
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2124
2314
|
auxiliaryMembraneFor(agentName) {
|
|
2125
2315
|
return this.membraneAdmitting((run, model) => this.withAuxiliaryAdmission(agentName, run, model));
|
|
2126
2316
|
}
|
|
@@ -2132,10 +2322,16 @@ export class AgentFramework {
|
|
|
2132
2322
|
* as a resident's are, so they bind its auxiliary calls too: a held model
|
|
2133
2323
|
* is refused without a call, and an auxiliary call's own stated wait is
|
|
2134
2324
|
* recorded for it. (An ephemeral run records nothing, its name being
|
|
2135
|
-
* single-use, so its context manager keeps the plain membrane.)
|
|
2325
|
+
* single-use, so its context manager keeps the plain membrane.) Its
|
|
2326
|
+
* compression is the background work a resident's is, whether a
|
|
2327
|
+
* maintenance tick or a drain after a message starts it, so it is withdrawn
|
|
2328
|
+
* as a resident's is: a call made while a surgery holds the store, or once
|
|
2329
|
+
* stop() has closed provider admission, is withdrawn without being sent.
|
|
2136
2330
|
*/
|
|
2137
2331
|
conversationAuxiliaryMembraneFor(agentName) {
|
|
2138
2332
|
return this.membraneAdmitting(async (run, model) => {
|
|
2333
|
+
this.withdrawMaintenanceUnderSurgery();
|
|
2334
|
+
this.withdrawMaintenanceWhenStopping(agentName);
|
|
2139
2335
|
this.refuseHeldProviderModel(agentName, model);
|
|
2140
2336
|
try {
|
|
2141
2337
|
return await run();
|
|
@@ -2311,17 +2507,26 @@ export class AgentFramework {
|
|
|
2311
2507
|
return undefined;
|
|
2312
2508
|
}
|
|
2313
2509
|
/** Why an ephemeral run ended instead of sleeping a provider's stated wait,
|
|
2314
|
-
* for its caller
|
|
2510
|
+
* for its caller: the text, and the wait itself, which its rejection
|
|
2511
|
+
* carries (ProviderWaitNotSleptError). */
|
|
2315
2512
|
statedWaitNotSlept(error) {
|
|
2316
|
-
|
|
2513
|
+
// Called only after nonOwningProviderWait answered 'fail', which takes a
|
|
2514
|
+
// numeric stated wait.
|
|
2515
|
+
const stated = isMembraneError(error) && typeof error.retryAfterMs === 'number' ? error.retryAfterMs : Number.NaN;
|
|
2317
2516
|
const cap = `${PROVIDER_ACCELERATION_MAX_COOLDOWN_MS / 1000} s`;
|
|
2318
|
-
if (
|
|
2319
|
-
return
|
|
2320
|
-
|
|
2517
|
+
if (Number.isNaN(stated)) {
|
|
2518
|
+
return {
|
|
2519
|
+
error: `${error.message} (the provider's stated wait is not a number, and an ephemeral run sleeps only a ` +
|
|
2520
|
+
`stated wait of ${cap} or less, so it ends now)`,
|
|
2521
|
+
statedWaitMs: Number.NaN,
|
|
2522
|
+
};
|
|
2321
2523
|
}
|
|
2322
2524
|
const asked = Number.isFinite(stated) ? `${Math.ceil(stated / 1000)} s` : 'longer than can be represented';
|
|
2323
|
-
return
|
|
2324
|
-
|
|
2525
|
+
return {
|
|
2526
|
+
error: `${error.message} (the provider asked to wait ${asked}; an ephemeral run doesn't sleep a stated wait ` +
|
|
2527
|
+
`longer than ${cap}, so it ends now)`,
|
|
2528
|
+
statedWaitMs: stated,
|
|
2529
|
+
};
|
|
2325
2530
|
}
|
|
2326
2531
|
/** When a hold's timer next fires: at its deadline, stepped past what one
|
|
2327
2532
|
* timer can hold. A hold with no deadline (a wait held until released, or
|
|
@@ -2627,7 +2832,28 @@ export class AgentFramework {
|
|
|
2627
2832
|
this.maintenancePass = null;
|
|
2628
2833
|
});
|
|
2629
2834
|
}
|
|
2835
|
+
/** resume()'s refusal while a surgery holds the store. */
|
|
2836
|
+
refuseResumeUnderSurgery() {
|
|
2837
|
+
if (!this.surgeryHold)
|
|
2838
|
+
return;
|
|
2839
|
+
throw new OperatorActionError('agent-busy', `Cannot resume: a live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} holds the store — retry when it completes`);
|
|
2840
|
+
}
|
|
2841
|
+
/** Whether maintenance work is running: a pass, or an over-budget drain kick. */
|
|
2842
|
+
maintenanceRunning() {
|
|
2843
|
+
return this.maintenancePass !== null || this.overBudgetDrains.size > 0;
|
|
2844
|
+
}
|
|
2845
|
+
/** Wait for the maintenance work that is running, whatever its outcome. */
|
|
2846
|
+
async settleMaintenancePass() {
|
|
2847
|
+
await Promise.allSettled([...(this.maintenancePass ? [this.maintenancePass] : []), ...this.overBudgetDrains]);
|
|
2848
|
+
}
|
|
2630
2849
|
async runQueuedMaintenance() {
|
|
2850
|
+
// A surgery holds the store while it switches or forks a branch. No pass
|
|
2851
|
+
// starts while it's held (the timer's next pass after it runs as usual),
|
|
2852
|
+
// and a running pass starts no new tick. The surgery waits for that pass
|
|
2853
|
+
// to finish before it moves the branch, so no maintenance work overlaps
|
|
2854
|
+
// the move, and a failure a pass meets is never the surgery's doing.
|
|
2855
|
+
if (this.surgeryHold)
|
|
2856
|
+
return;
|
|
2631
2857
|
// A guard whose agent has left the map gets no activation to retry its
|
|
2632
2858
|
// queue; retry it here. It leaves busyGuards once nothing of it is left.
|
|
2633
2859
|
const live = new Set([...this.agents.values()].map((agent) => agent.toolResultGuard));
|
|
@@ -2641,6 +2867,10 @@ export class AgentFramework {
|
|
|
2641
2867
|
console.error(`[tool-result-guard] agent=${name} storage retry failed during maintenance:`, error);
|
|
2642
2868
|
}
|
|
2643
2869
|
}
|
|
2870
|
+
for (const name of this.maintenanceFailureReported.keys()) {
|
|
2871
|
+
if (!this.agents.has(name))
|
|
2872
|
+
this.maintenanceFailureReported.delete(name);
|
|
2873
|
+
}
|
|
2644
2874
|
const queued = [...this.agents.values()].flatMap((agent) => {
|
|
2645
2875
|
try {
|
|
2646
2876
|
agent.toolResultGuard.flushUnrecorded();
|
|
@@ -2648,28 +2878,53 @@ export class AgentFramework {
|
|
|
2648
2878
|
catch (error) {
|
|
2649
2879
|
console.error(`[tool-result-guard] agent=${agent.name} storage retry failed during maintenance:`, error);
|
|
2650
2880
|
}
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2881
|
+
// Whatever throws while one agent's maintenance is prepared is that
|
|
2882
|
+
// agent's failure, reported for it alone, while the others run.
|
|
2883
|
+
const failedAgent = (error) => {
|
|
2884
|
+
const record = {
|
|
2885
|
+
agentName: agent.name,
|
|
2886
|
+
startedAt: Date.now(),
|
|
2887
|
+
ticks: 0,
|
|
2888
|
+
readyBefore: false,
|
|
2889
|
+
};
|
|
2890
|
+
record.finishedAt = record.startedAt;
|
|
2891
|
+
this.reportMaintenanceFailure(agent.name, record, error instanceof Error ? String(error.message) : failureDescription(error));
|
|
2892
|
+
return [{ agent, record, failed: true }];
|
|
2893
|
+
};
|
|
2894
|
+
let cm;
|
|
2895
|
+
let state;
|
|
2896
|
+
try {
|
|
2897
|
+
cm = agent.getContextManager();
|
|
2898
|
+
const tools = this.compressionToolsForAgent(agent.name);
|
|
2899
|
+
cm.setToolDefinitions(tools);
|
|
2900
|
+
if (this.providerGateBlocked(agent.name))
|
|
2901
|
+
return [];
|
|
2902
|
+
state = this.contextReadiness(cm);
|
|
2903
|
+
}
|
|
2904
|
+
catch (error) {
|
|
2905
|
+
return failedAgent(error);
|
|
2906
|
+
}
|
|
2907
|
+
if ('ready' in state && state.ready) {
|
|
2908
|
+
this.maintenanceFailureReported.delete(agent.name);
|
|
2657
2909
|
return [];
|
|
2658
|
-
|
|
2910
|
+
}
|
|
2659
2911
|
const progress = this.contextProgress(cm);
|
|
2660
2912
|
const record = {
|
|
2661
2913
|
agentName: agent.name,
|
|
2662
2914
|
startedAt: Date.now(),
|
|
2663
2915
|
ticks: 0,
|
|
2664
2916
|
readyBefore: false,
|
|
2665
|
-
...(pending ? { pendingBefore: pending } : {}),
|
|
2917
|
+
...('pending' in state && state.pending ? { pendingBefore: state.pending } : {}),
|
|
2666
2918
|
...(progress ? { progressBefore: progress } : {}),
|
|
2667
2919
|
};
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2920
|
+
if ('error' in state) {
|
|
2921
|
+
// Its readiness can't be read: that agent's failure, reported for it
|
|
2922
|
+
// alone, while the others run.
|
|
2923
|
+
record.finishedAt = Date.now();
|
|
2924
|
+
this.reportMaintenanceFailure(agent.name, record, state.error);
|
|
2925
|
+
return [{ agent, record, failed: true }];
|
|
2926
|
+
}
|
|
2927
|
+
return [{ agent, cm, record, failed: false }];
|
|
2673
2928
|
});
|
|
2674
2929
|
if (queued.length === 0)
|
|
2675
2930
|
return;
|
|
@@ -2680,54 +2935,141 @@ export class AgentFramework {
|
|
|
2680
2935
|
};
|
|
2681
2936
|
this.currentMaintenanceRun = run;
|
|
2682
2937
|
try {
|
|
2683
|
-
|
|
2938
|
+
const running = queued.filter((item) => !item.failed);
|
|
2939
|
+
// Settled, not all: the pass ends only when every agent's work has.
|
|
2940
|
+
const outcomes = await Promise.allSettled(running.map(async ({ agent, cm, record }) => {
|
|
2941
|
+
// A tick whose provider call a surgery or stop() withdrew ends this
|
|
2942
|
+
// agent's work for the pass, as the hold or the stop would have before
|
|
2943
|
+
// its next tick: it is no failure, and what the failure reports
|
|
2944
|
+
// remember stays as it is.
|
|
2945
|
+
let withdrawn = false;
|
|
2684
2946
|
try {
|
|
2685
|
-
for (let i = 0; i < MAINTENANCE_TICKS_PER_PASS && this.running && !cm.isReady(); i++) {
|
|
2947
|
+
for (let i = 0; i < MAINTENANCE_TICKS_PER_PASS && this.running && !this.surgeryHold && !cm.isReady(); i++) {
|
|
2686
2948
|
await cm.tick();
|
|
2687
2949
|
record.ticks++;
|
|
2688
2950
|
}
|
|
2689
2951
|
}
|
|
2690
2952
|
catch (error) {
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
...(record.progressBefore ? { progress: record.progressBefore } : {}),
|
|
2699
|
-
},
|
|
2700
|
-
});
|
|
2953
|
+
if (error instanceof MaintenanceWithdrawnError) {
|
|
2954
|
+
withdrawn = true;
|
|
2955
|
+
console.error(`[context-maintenance] agent=${agent.name} stopped: ${error.message}`);
|
|
2956
|
+
}
|
|
2957
|
+
else {
|
|
2958
|
+
this.reportMaintenanceFailure(agent.name, record, error instanceof Error ? String(error.message) : failureDescription(error));
|
|
2959
|
+
}
|
|
2701
2960
|
}
|
|
2702
2961
|
finally {
|
|
2703
|
-
const pending = cm.getPendingWork()?.description;
|
|
2704
2962
|
record.finishedAt = Date.now();
|
|
2705
|
-
|
|
2706
|
-
if (
|
|
2707
|
-
|
|
2963
|
+
const state = this.contextReadiness(cm);
|
|
2964
|
+
if ('error' in state) {
|
|
2965
|
+
// A run records an agent's first failure: a readiness refusal
|
|
2966
|
+
// after a tick that failed is read again by the next pass.
|
|
2967
|
+
if (record.error === undefined && !withdrawn)
|
|
2968
|
+
this.reportMaintenanceFailure(agent.name, record, state.error);
|
|
2969
|
+
}
|
|
2970
|
+
else {
|
|
2971
|
+
record.readyAfter = state.ready;
|
|
2972
|
+
if (state.pending)
|
|
2973
|
+
record.pendingAfter = state.pending;
|
|
2974
|
+
if (record.error === undefined && !withdrawn)
|
|
2975
|
+
this.maintenanceFailureReported.delete(agent.name);
|
|
2976
|
+
}
|
|
2708
2977
|
const progress = this.contextProgress(cm);
|
|
2709
2978
|
if (progress)
|
|
2710
2979
|
record.progressAfter = progress;
|
|
2711
2980
|
}
|
|
2712
2981
|
}));
|
|
2982
|
+
for (const outcome of outcomes) {
|
|
2983
|
+
if (outcome.status === 'rejected') {
|
|
2984
|
+
console.error('[context-maintenance] reporting an agent\'s failure failed:', describeFailureWithFrames(outcome.reason));
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2713
2987
|
}
|
|
2714
2988
|
finally {
|
|
2715
2989
|
run.finishedAt = Date.now();
|
|
2716
2990
|
run.agents.sort((a, b) => a.agentName.localeCompare(b.agentName));
|
|
2717
2991
|
this.currentMaintenanceRun = null;
|
|
2718
|
-
|
|
2992
|
+
// A pass that only met failures already reported, and took no tick,
|
|
2993
|
+
// adds nothing the history doesn't hold, and a lasting failure would
|
|
2994
|
+
// otherwise fill it in minutes, pushing out the runs that did work. One
|
|
2995
|
+
// an operator asked for is kept: it's the pass their maintain reports.
|
|
2996
|
+
const requested = this.maintenancePassRequested;
|
|
2997
|
+
this.maintenancePassRequested = false;
|
|
2998
|
+
if (requested || !run.agents.every((record) => record.ticks === 0 && this.maintenanceRepeats.has(record)))
|
|
2999
|
+
this.maintenanceHistory.unshift(run);
|
|
2719
3000
|
if (this.maintenanceHistory.length > 50)
|
|
2720
3001
|
this.maintenanceHistory.length = 50;
|
|
2721
3002
|
}
|
|
2722
3003
|
}
|
|
3004
|
+
/**
|
|
3005
|
+
* One agent's maintenance failure: recorded on its run, bounded as every
|
|
3006
|
+
* failure sink is (`record.error`), and logged and alerted for that agent
|
|
3007
|
+
* with the whole text. A failure that lasts, such as a manager whose
|
|
3008
|
+
* strategy failed to initialize, meets every pass, a few seconds apart. It
|
|
3009
|
+
* is logged and alerted again only when its whole text changes, once
|
|
3010
|
+
* opsAlert's cooldown for it has passed since the webhook last posted it
|
|
3011
|
+
* (so the webhook, which posts a kind at most once per cooldown, hears a
|
|
3012
|
+
* failure that lasts), or after a pass in which that agent's maintenance
|
|
3013
|
+
* went without a failure (maintenanceFailureReported). A record whose
|
|
3014
|
+
* failure isn't reported again is marked as a repeat, and a pass whose
|
|
3015
|
+
* every record is a repeat that took no tick stays out of the history.
|
|
3016
|
+
*/
|
|
3017
|
+
reportMaintenanceFailure(agentName, record, wholeError) {
|
|
3018
|
+
record.error = boundFailureText(wholeError);
|
|
3019
|
+
const reported = this.maintenanceFailureReported.get(agentName);
|
|
3020
|
+
const now = Date.now();
|
|
3021
|
+
if (reported && reported.text === wholeError) {
|
|
3022
|
+
// The same failure again is alerted once the webhook's cooldown for it
|
|
3023
|
+
// has passed, counted from the webhook's last post of it: a report of
|
|
3024
|
+
// changed text that the webhook didn't post doesn't push the reminder
|
|
3025
|
+
// back. With no webhook, it's counted from this failure's last report.
|
|
3026
|
+
const since = this.opsAlertLastSent.get(`${agentName}:context-maintenance-failed`) ?? reported.at;
|
|
3027
|
+
if (now - since < this.opsAlertCooldownMs) {
|
|
3028
|
+
this.maintenanceRepeats.add(record);
|
|
3029
|
+
return;
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
this.maintenanceFailureReported.set(agentName, { text: wholeError, at: now });
|
|
3033
|
+
console.error(`[context-maintenance] agent=${agentName} failed:`, record.error);
|
|
3034
|
+
this.opsAlert('context-maintenance-failed', agentName, wholeError, {
|
|
3035
|
+
data: {
|
|
3036
|
+
scope: 'agent',
|
|
3037
|
+
...(record.pendingBefore ? { pending: record.pendingBefore } : {}),
|
|
3038
|
+
...(record.progressBefore ? { progress: record.progressBefore } : {}),
|
|
3039
|
+
},
|
|
3040
|
+
});
|
|
3041
|
+
}
|
|
3042
|
+
/**
|
|
3043
|
+
* An agent's context readiness, or the error that reading it threw. The
|
|
3044
|
+
* context manager refuses while its strategy's initialization is pending
|
|
3045
|
+
* or has failed (context-manager 0.14), and a strategy can refuse on its
|
|
3046
|
+
* own, as the autobiographical one does after a store-level branch switch
|
|
3047
|
+
* it hasn't reloaded for. Every such throw is that agent's failure.
|
|
3048
|
+
*/
|
|
3049
|
+
contextReadiness(cm) {
|
|
3050
|
+
try {
|
|
3051
|
+
const ready = cm.isReady();
|
|
3052
|
+
const pending = ready ? undefined : cm.getPendingWork()?.description;
|
|
3053
|
+
return pending ? { ready, pending } : { ready };
|
|
3054
|
+
}
|
|
3055
|
+
catch (error) {
|
|
3056
|
+
return { error: error instanceof Error ? String(error.message) : failureDescription(error) };
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
/** The strategy's progress snapshot, for diagnostics: one it can't give reads as none. */
|
|
2723
3060
|
contextProgress(cm) {
|
|
2724
|
-
|
|
2725
|
-
|
|
3061
|
+
try {
|
|
3062
|
+
const strategy = cm.getStrategy();
|
|
3063
|
+
if (typeof strategy.getProgressSnapshot !== 'function')
|
|
3064
|
+
return undefined;
|
|
3065
|
+
const progress = strategy.getProgressSnapshot();
|
|
3066
|
+
return progress && typeof progress === 'object'
|
|
3067
|
+
? progress
|
|
3068
|
+
: undefined;
|
|
3069
|
+
}
|
|
3070
|
+
catch {
|
|
2726
3071
|
return undefined;
|
|
2727
|
-
|
|
2728
|
-
return progress && typeof progress === 'object'
|
|
2729
|
-
? progress
|
|
2730
|
-
: undefined;
|
|
3072
|
+
}
|
|
2731
3073
|
}
|
|
2732
3074
|
/**
|
|
2733
3075
|
* A server's request or notification the host couldn't handle, answered
|
|
@@ -3431,14 +3773,15 @@ export class AgentFramework {
|
|
|
3431
3773
|
`advance merges to go lower). Applying; the live compile budget is unchanged.`);
|
|
3432
3774
|
}
|
|
3433
3775
|
}
|
|
3434
|
-
else if (preview.reason) {
|
|
3776
|
+
else if (preview.reason && agent.contextManagerAnswers()) {
|
|
3777
|
+
// A manager that refuses its queries refuses the update below too.
|
|
3435
3778
|
console.warn(`[budget-preflight] preview unavailable for ${agentName} (${preview.reason}); ` +
|
|
3436
3779
|
`applying without preflight`);
|
|
3437
3780
|
}
|
|
3438
3781
|
}
|
|
3439
3782
|
const result = agent.updateRuntimeSettings(patch);
|
|
3440
3783
|
if (opts?.persist !== false) {
|
|
3441
|
-
this.persistAgentRuntimeSettings(agentName,
|
|
3784
|
+
this.persistAgentRuntimeSettings(agentName, this.persistedRuntimeSettingsOf(agent));
|
|
3442
3785
|
}
|
|
3443
3786
|
this.recordOperatorAction({
|
|
3444
3787
|
kind: 'settings-update',
|
|
@@ -3455,7 +3798,7 @@ export class AgentFramework {
|
|
|
3455
3798
|
throw new Error(`Unknown agent: ${agentName}`);
|
|
3456
3799
|
const result = agent.resetRuntimeSettings(keys);
|
|
3457
3800
|
if (opts?.persist !== false) {
|
|
3458
|
-
this.persistAgentRuntimeSettings(agentName,
|
|
3801
|
+
this.persistAgentRuntimeSettings(agentName, this.persistedRuntimeSettingsOf(agent));
|
|
3459
3802
|
}
|
|
3460
3803
|
this.recordOperatorAction({
|
|
3461
3804
|
kind: 'settings-reset',
|
|
@@ -3471,7 +3814,7 @@ export class AgentFramework {
|
|
|
3471
3814
|
if (!agent)
|
|
3472
3815
|
throw new Error(`Unknown agent: ${agentName}`);
|
|
3473
3816
|
const result = agent.cancelRuntimeSettingsTransition();
|
|
3474
|
-
this.persistAgentRuntimeSettings(agentName,
|
|
3817
|
+
this.persistAgentRuntimeSettings(agentName, this.persistedRuntimeSettingsOf(agent));
|
|
3475
3818
|
this.recordOperatorAction({
|
|
3476
3819
|
kind: 'settings-cancel-transition',
|
|
3477
3820
|
agent: agentName,
|
|
@@ -3547,6 +3890,10 @@ export class AgentFramework {
|
|
|
3547
3890
|
* inference-failure accounting.
|
|
3548
3891
|
*/
|
|
3549
3892
|
async quiesce(opts) {
|
|
3893
|
+
this.refuseOperatorAfterStop('quiesce');
|
|
3894
|
+
return this.trackOperatorOperation('host:quiesce', () => this.runQuiesce(opts));
|
|
3895
|
+
}
|
|
3896
|
+
async runQuiesce(opts) {
|
|
3550
3897
|
if (this.quiesced) {
|
|
3551
3898
|
// Idempotent — EXCEPT abandon escalation: a first quiesce that timed
|
|
3552
3899
|
// out undrained must be escalatable with a second quiesce({abandon})
|
|
@@ -3594,6 +3941,7 @@ export class AgentFramework {
|
|
|
3594
3941
|
// latter becomes a turn the moment its auxiliary settles unless the
|
|
3595
3942
|
// continuation's quiesce recheck requeues it — wait for that to happen).
|
|
3596
3943
|
while (this.quiesced &&
|
|
3944
|
+
!this.queue?.isClosed &&
|
|
3597
3945
|
(this.activeTurnTokens.size > 0 || this.parkedAdmissionCount() > 0) &&
|
|
3598
3946
|
Date.now() < deadline) {
|
|
3599
3947
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
@@ -3606,6 +3954,13 @@ export class AgentFramework {
|
|
|
3606
3954
|
console.error('[host-mode] quiesce superseded by a concurrent resume during drain');
|
|
3607
3955
|
return this.getHostModeStatus();
|
|
3608
3956
|
}
|
|
3957
|
+
// Stop cuts this drain short, including any requested abandon. It cancels
|
|
3958
|
+
// streams and waits for the event loop; a token with no stream settles
|
|
3959
|
+
// on its own. Return the status as it stands and keep the durable quiesce.
|
|
3960
|
+
if (this.queue?.isClosed) {
|
|
3961
|
+
console.error('[host-mode] quiesce stopped waiting for its drain: the framework is stopping');
|
|
3962
|
+
return this.getHostModeStatus();
|
|
3963
|
+
}
|
|
3609
3964
|
let abandoned = false;
|
|
3610
3965
|
let unabandonable = [];
|
|
3611
3966
|
if (this.activeTurnTokens.size > 0 && opts?.abandon) {
|
|
@@ -3657,9 +4012,11 @@ export class AgentFramework {
|
|
|
3657
4012
|
`(status=${state?.status ?? 'unknown'}) — it must settle on its own`);
|
|
3658
4013
|
}
|
|
3659
4014
|
}
|
|
3660
|
-
// Bounded grace for
|
|
4015
|
+
// Bounded grace for cancelled streams. Stop cancels streams too; a turn
|
|
4016
|
+
// with no stream settles on its own. Report the abandon that already ran,
|
|
4017
|
+
// even if stop cuts its grace short.
|
|
3661
4018
|
const grace = Date.now() + 10_000;
|
|
3662
|
-
while (this.quiesced && this.activeTurnTokens.size > 0 && Date.now() < grace) {
|
|
4019
|
+
while (this.quiesced && !this.queue?.isClosed && this.activeTurnTokens.size > 0 && Date.now() < grace) {
|
|
3663
4020
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
3664
4021
|
}
|
|
3665
4022
|
// Only turns still alive after the grace are worth reporting.
|
|
@@ -3675,18 +4032,32 @@ export class AgentFramework {
|
|
|
3675
4032
|
* strategy cannot preview would hold hosts hostage to a diagnostic.
|
|
3676
4033
|
*/
|
|
3677
4034
|
async resume(opts) {
|
|
4035
|
+
this.refuseOperatorAfterStop('resume');
|
|
4036
|
+
return this.trackOperatorOperation('host:resume', () => this.runResume(opts));
|
|
4037
|
+
}
|
|
4038
|
+
async runResume(opts) {
|
|
3678
4039
|
if (!this.quiesced)
|
|
3679
4040
|
return this.getHostModeStatus();
|
|
4041
|
+
// A surgery switches or forks the branch while it holds the store, and a
|
|
4042
|
+
// manager whose initialization that left pending refuses its settings
|
|
4043
|
+
// read: resume once it's done, as the other operator calls do. Checked
|
|
4044
|
+
// again after the waits below, since a surgery can take the store while
|
|
4045
|
+
// resume waits for a pass or a preview.
|
|
4046
|
+
this.refuseResumeUnderSurgery();
|
|
3680
4047
|
// The canonical operator flow is `maintain; resume`, and a timer-driven
|
|
3681
4048
|
// pass may still be mid-flight: previewContext refuses to overlap one
|
|
3682
4049
|
// ("already running"), which used to degrade the gate to "no opinion".
|
|
3683
4050
|
// Wait for the pass first, then retry a few times for the case where a
|
|
3684
4051
|
// fresh pass started in between.
|
|
3685
|
-
if (this.
|
|
3686
|
-
await this.
|
|
3687
|
-
}
|
|
4052
|
+
if (this.maintenanceRunning())
|
|
4053
|
+
await this.settleMaintenancePass();
|
|
3688
4054
|
const failing = [];
|
|
3689
4055
|
for (const agentName of this.agents.keys()) {
|
|
4056
|
+
// A preview that isn't available gives no verdict, and resume goes on
|
|
4057
|
+
// for the others. That includes an agent whose context manager refuses
|
|
4058
|
+
// its queries, as when its strategy failed to initialize: that manager
|
|
4059
|
+
// refuses its compiles and writes until a switchBranch or setStrategy
|
|
4060
|
+
// succeeds, so the agent can't run over budget, and maintenance reports it.
|
|
3690
4061
|
let preview = this.previewAgentRuntimeSettings(agentName);
|
|
3691
4062
|
for (let attempt = 0; attempt < 8 && !preview.available && preview.reason === 'preview_in_flight'; attempt++) {
|
|
3692
4063
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
@@ -3702,6 +4073,13 @@ export class AgentFramework {
|
|
|
3702
4073
|
`(${preview.reason}) — proceeding without a verdict`);
|
|
3703
4074
|
}
|
|
3704
4075
|
}
|
|
4076
|
+
this.refuseResumeUnderSurgery();
|
|
4077
|
+
// stop() closes the queue first. If it has begun, before this call or
|
|
4078
|
+
// during the waits above, leave the host quiesced, as it will boot,
|
|
4079
|
+
// rather than reopen what stop() is closing.
|
|
4080
|
+
if (this.queue?.isClosed) {
|
|
4081
|
+
throw new Error('resume refused: the framework is stopping, so the host stays quiesced');
|
|
4082
|
+
}
|
|
3705
4083
|
if (failing.length > 0 && !opts?.force) {
|
|
3706
4084
|
const detail = failing.map(({ agentName, preview }) => `${agentName}: folded floor ${preview.effective.finalTokens} > hard budget ` +
|
|
3707
4085
|
`${preview.effective.budgetTokens}` +
|
|
@@ -3749,9 +4127,10 @@ export class AgentFramework {
|
|
|
3749
4127
|
this.completeMcplDataPlaneGate(barrier);
|
|
3750
4128
|
}
|
|
3751
4129
|
catch (error) {
|
|
3752
|
-
// The host
|
|
3753
|
-
//
|
|
3754
|
-
//
|
|
4130
|
+
// The host has resumed, so report the failed barrier without undoing
|
|
4131
|
+
// that. While serving, failMcplDataPlaneGate recycles the
|
|
4132
|
+
// connections and their reconnect flows reopen the data planes;
|
|
4133
|
+
// while the host stops, it releases the barrier instead.
|
|
3755
4134
|
await this.failMcplDataPlaneGate(barrier, 'quiesce resume', error);
|
|
3756
4135
|
}
|
|
3757
4136
|
}
|
|
@@ -3764,7 +4143,9 @@ export class AgentFramework {
|
|
|
3764
4143
|
// mid-quiesce boots quiesced but with these queues empty (sources like
|
|
3765
4144
|
// heartbeats re-deliver on their own cadence; one-shot wakes are lost).
|
|
3766
4145
|
// Deferred context WRITES are persisted while quiesced and survive.
|
|
3767
|
-
console.error(
|
|
4146
|
+
console.error(this.queue?.isClosed
|
|
4147
|
+
? `[host-mode] resumed — ${releasedRequests} parked wake(s) won't run because the framework is stopping`
|
|
4148
|
+
: `[host-mode] resumed — ${releasedRequests} parked wake(s) released`);
|
|
3768
4149
|
}
|
|
3769
4150
|
return this.getHostModeStatus();
|
|
3770
4151
|
}
|
|
@@ -4057,26 +4438,38 @@ export class AgentFramework {
|
|
|
4057
4438
|
* true in quiesce mode precisely so this machinery stays hot.
|
|
4058
4439
|
*/
|
|
4059
4440
|
async maintenanceTick() {
|
|
4441
|
+
// A pass already running is stop()'s to wait for; a new one isn't started.
|
|
4442
|
+
this.refuseOperatorAfterStop('maintain');
|
|
4060
4443
|
if (!this.running) {
|
|
4061
|
-
// startQueuedMaintenance no-ops on a
|
|
4062
|
-
// a
|
|
4444
|
+
// startQueuedMaintenance no-ops on a framework that was never started
|
|
4445
|
+
// (a stopping one is refused above): a bare snapshot would masquerade
|
|
4446
|
+
// as a completed pass, so say so.
|
|
4063
4447
|
console.warn('[host-mode] maintenanceTick: framework is not running — no pass executed');
|
|
4064
4448
|
return { ...this.getContextMaintenanceSnapshot(), ran: false };
|
|
4065
4449
|
}
|
|
4450
|
+
if (this.surgeryHold) {
|
|
4451
|
+
// No pass starts while a surgery holds the store (runQueuedMaintenance).
|
|
4452
|
+
console.warn(`[host-mode] maintenanceTick: a ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} holds the store — no pass executed`);
|
|
4453
|
+
return { ...this.getContextMaintenanceSnapshot(), ran: false };
|
|
4454
|
+
}
|
|
4455
|
+
this.maintenancePassRequested = true;
|
|
4066
4456
|
this.startQueuedMaintenance();
|
|
4067
4457
|
await this.maintenancePass;
|
|
4458
|
+
// A pass with nothing queued records no run, and the next pass wasn't asked for.
|
|
4459
|
+
this.maintenancePassRequested = false;
|
|
4068
4460
|
return { ...this.getContextMaintenanceSnapshot(), ran: true };
|
|
4069
4461
|
}
|
|
4070
4462
|
/** Counts-only context-maintenance diagnostics for authenticated debug UIs. */
|
|
4071
4463
|
getContextMaintenanceSnapshot() {
|
|
4072
4464
|
const agents = [...this.agents.values()].map((agent) => {
|
|
4073
4465
|
const cm = agent.getContextManager();
|
|
4074
|
-
const
|
|
4466
|
+
const state = this.contextReadiness(cm);
|
|
4075
4467
|
const progress = this.contextProgress(cm);
|
|
4076
4468
|
return {
|
|
4077
4469
|
agentName: agent.name,
|
|
4078
|
-
|
|
4079
|
-
|
|
4470
|
+
...('error' in state
|
|
4471
|
+
? { ready: false, error: boundFailureText(state.error) }
|
|
4472
|
+
: { ready: state.ready, ...(state.pending ? { pending: state.pending } : {}) }),
|
|
4080
4473
|
...(progress ? { progress } : {}),
|
|
4081
4474
|
};
|
|
4082
4475
|
});
|
|
@@ -4093,13 +4486,28 @@ export class AgentFramework {
|
|
|
4093
4486
|
* were activated right now — WITHOUT running inference, opening a stream, or
|
|
4094
4487
|
* mutating agent state. Intended for debug/preview tooling.
|
|
4095
4488
|
*
|
|
4096
|
-
*
|
|
4097
|
-
*
|
|
4098
|
-
* `
|
|
4099
|
-
*
|
|
4100
|
-
*
|
|
4101
|
-
*
|
|
4102
|
-
*
|
|
4489
|
+
* What a preview commits (default): nothing of its own. It compiles as a
|
|
4490
|
+
* dry run (`dryRun`), filters tools and assembles the system prompt, then
|
|
4491
|
+
* delegates to `Agent.buildActivationRequest`. No tokens are spent, no
|
|
4492
|
+
* external MCPL server is contacted, no fold resolution is persisted and
|
|
4493
|
+
* none of the compression its fold plan calls for is queued; the consumed
|
|
4494
|
+
* watermark and any prepared budget change are left alone; and once
|
|
4495
|
+
* context-manager's thinking binding (context-manager#160, not yet
|
|
4496
|
+
* released) ships, no stamps are committed and no compile awaits
|
|
4497
|
+
* acceptance. In context-manager 0.14 its dry run leaves nothing else
|
|
4498
|
+
* behind but the release of an expired compression hold, as any compile
|
|
4499
|
+
* does (see `Agent.compileWithInjections`). The preview keeps in-memory
|
|
4500
|
+
* bookkeeping of its own, such as handing the context manager the
|
|
4501
|
+
* compression tool definitions, as every activation request does.
|
|
4502
|
+
* Under the autobiographical strategy, a budget the context can't fit
|
|
4503
|
+
* still throws, as for a committing compile: a dry run skips only
|
|
4504
|
+
* context-manager's post-pick check, and an adaptive fold plan that
|
|
4505
|
+
* doesn't fit then overruns at the middle's emission, with a refusal that
|
|
4506
|
+
* names the tokens and the limit. Windowed passthrough's newest message
|
|
4507
|
+
* alone is the one window that comes back larger than the budget. For a
|
|
4508
|
+
* verdict instead of the refusal, `previewContextSettings` returns
|
|
4509
|
+
* context-manager's own at a budget, with the agent's `maxTokens` reserved
|
|
4510
|
+
* for the response.
|
|
4103
4511
|
*
|
|
4104
4512
|
* The trade-off is fidelity: the dynamically-gathered ContextInjection[]
|
|
4105
4513
|
* (module `gatherContext` + MCPL `beforeInference` hooks) are NOT included
|
|
@@ -4126,13 +4534,13 @@ export class AgentFramework {
|
|
|
4126
4534
|
return request;
|
|
4127
4535
|
};
|
|
4128
4536
|
// An explicit budget compiles against a HYPOTHETICAL window instead of the
|
|
4129
|
-
// agent's live one.
|
|
4130
|
-
//
|
|
4131
|
-
//
|
|
4132
|
-
// Default: no dynamic injection gathering →
|
|
4133
|
-
//
|
|
4537
|
+
// agent's live one. Every preview is a dry run, so none of them settles
|
|
4538
|
+
// a converging descent or commits what a sent compile commits (see the
|
|
4539
|
+
// doc comment above).
|
|
4540
|
+
// Default: no dynamic injection gathering → no inference, no Chronicle
|
|
4541
|
+
// writes of its own, no external RPC. Opt in explicitly.
|
|
4134
4542
|
if (!opts?.injections) {
|
|
4135
|
-
return capture(await agent.buildActivationRequest(tools, undefined, opts?.budget, presentation?.available ?? tools));
|
|
4543
|
+
return capture(await agent.buildActivationRequest(tools, undefined, opts?.budget, presentation?.available ?? tools, { dryRun: true }));
|
|
4136
4544
|
}
|
|
4137
4545
|
// Full-fidelity path: mirrors startAgentStream's injection gathering.
|
|
4138
4546
|
// NOT transparent — see the doc comment above.
|
|
@@ -4162,7 +4570,7 @@ export class AgentFramework {
|
|
|
4162
4570
|
console.error('beforeInference hook error (preview):', error);
|
|
4163
4571
|
}
|
|
4164
4572
|
}
|
|
4165
|
-
return capture(await agent.buildActivationRequest(tools, injections, opts?.budget, presentation?.available ?? tools));
|
|
4573
|
+
return capture(await agent.buildActivationRequest(tools, injections, opts?.budget, presentation?.available ?? tools, { dryRun: true }));
|
|
4166
4574
|
}
|
|
4167
4575
|
/**
|
|
4168
4576
|
* Non-committing preview of the FOLD PLAN at a hypothetical budget and/or
|
|
@@ -4171,9 +4579,11 @@ export class AgentFramework {
|
|
|
4171
4579
|
*
|
|
4172
4580
|
* Unlike `previewActivation` (which renders a request), this returns the
|
|
4173
4581
|
* picker's plan: resulting tokens, whether it fits, how many summaries would
|
|
4174
|
-
* have to be produced first, and the per-chunk fold levels.
|
|
4175
|
-
*
|
|
4176
|
-
*
|
|
4582
|
+
* have to be produced first, and the per-chunk fold levels. It commits
|
|
4583
|
+
* nothing: its select is a dry run, which in context-manager 0.14 leaves
|
|
4584
|
+
* what a select leaves behind as the last committing compile left it —
|
|
4585
|
+
* see `AutobiographicalStrategy.previewContext` and
|
|
4586
|
+
* `Agent.compileWithInjections`.
|
|
4177
4587
|
*
|
|
4178
4588
|
* Returns null when the agent's strategy has no fold plan (non-adaptive).
|
|
4179
4589
|
*/
|
|
@@ -4181,6 +4591,9 @@ export class AgentFramework {
|
|
|
4181
4591
|
const agent = this.agents.get(agentName);
|
|
4182
4592
|
if (!agent)
|
|
4183
4593
|
throw new Error(`Unknown agent: ${agentName}`);
|
|
4594
|
+
return this.previewContextFor(agent, budgetTokens, overrides, opts);
|
|
4595
|
+
}
|
|
4596
|
+
previewContextFor(agent, budgetTokens, overrides, opts) {
|
|
4184
4597
|
const cm = agent.getContextManager();
|
|
4185
4598
|
if (typeof cm.previewContext !== 'function')
|
|
4186
4599
|
return null;
|
|
@@ -4204,16 +4617,42 @@ export class AgentFramework {
|
|
|
4204
4617
|
* the compile budget and rejected safe-by-construction paced descents.
|
|
4205
4618
|
*
|
|
4206
4619
|
* Never throws for preview-layer reasons: strategy without previewContext,
|
|
4207
|
-
* preview overlap, stale branch generation,
|
|
4208
|
-
* adaptive) path
|
|
4209
|
-
*
|
|
4620
|
+
* preview overlap, stale branch generation, the hierarchical (non-
|
|
4621
|
+
* adaptive) path, and a context manager that refuses its queries (its
|
|
4622
|
+
* strategy's initialization pending or failed) all come back as
|
|
4623
|
+
* `{available: false, reason}`. Unknown agent still throws, matching the
|
|
4624
|
+
* sibling accessors, and so does a patch whose values the agent refuses,
|
|
4625
|
+
* whatever its context manager's state. Whether the strategy takes live
|
|
4626
|
+
* tail and pace settings is the manager's to say, so while it refuses, a
|
|
4627
|
+
* patch that sets them gets no verdict either.
|
|
4210
4628
|
*/
|
|
4211
4629
|
previewAgentRuntimeSettings(agentName, patch) {
|
|
4212
4630
|
const agent = this.agents.get(agentName);
|
|
4213
4631
|
if (!agent)
|
|
4214
4632
|
throw new Error(`Unknown agent: ${agentName}`);
|
|
4215
|
-
|
|
4216
|
-
|
|
4633
|
+
return this.previewRuntimeSettingsFor(agent, patch);
|
|
4634
|
+
}
|
|
4635
|
+
/** previewAgentRuntimeSettings for an agent in hand: boot restore previews one not yet registered. */
|
|
4636
|
+
previewRuntimeSettingsFor(agent, patch) {
|
|
4637
|
+
// A patch whose values the agent refuses throws, whatever its context
|
|
4638
|
+
// manager's state.
|
|
4639
|
+
if (patch && Object.keys(patch).length > 0)
|
|
4640
|
+
agent.checkRuntimeSettingsPatch(patch);
|
|
4641
|
+
let plan;
|
|
4642
|
+
let settings;
|
|
4643
|
+
try {
|
|
4644
|
+
plan = agent.planRuntimeSettings(patch);
|
|
4645
|
+
settings = agent.getRuntimeSettings();
|
|
4646
|
+
}
|
|
4647
|
+
catch (error) {
|
|
4648
|
+
// A manager that refuses its queries gives no verdict, as a preview
|
|
4649
|
+
// that can't be computed gives none: the agent's own state still says
|
|
4650
|
+
// its budget and the patch's path, and whatever stopped the preview is
|
|
4651
|
+
// its reason. When the manager answers, anything else throws as before.
|
|
4652
|
+
if (agent.contextManagerAnswers())
|
|
4653
|
+
throw error;
|
|
4654
|
+
return { available: false, reason: previewStoppedReason(error), ...agent.runtimeBudgetPath(patch) };
|
|
4655
|
+
}
|
|
4217
4656
|
const base = {
|
|
4218
4657
|
available: false,
|
|
4219
4658
|
path: plan.path,
|
|
@@ -4223,27 +4662,14 @@ export class AgentFramework {
|
|
|
4223
4662
|
};
|
|
4224
4663
|
const runPreview = (budgetTokens) => {
|
|
4225
4664
|
try {
|
|
4226
|
-
const result = this.
|
|
4665
|
+
const result = this.previewContextFor(agent, budgetTokens, Object.keys(plan.overrides).length > 0 ? plan.overrides : undefined);
|
|
4227
4666
|
if (result === null || typeof result !== 'object') {
|
|
4228
4667
|
return { reason: 'no_preview_support' };
|
|
4229
4668
|
}
|
|
4230
4669
|
return { result: result };
|
|
4231
4670
|
}
|
|
4232
4671
|
catch (error) {
|
|
4233
|
-
|
|
4234
|
-
// Structured mapping of the strategy's known throw paths. Message
|
|
4235
|
-
// matching is fragile by nature, so unknown messages surface verbatim
|
|
4236
|
-
// as the reason rather than being swallowed or rethrown.
|
|
4237
|
-
if (message.includes('requires reinitialization for the current branch generation')) {
|
|
4238
|
-
return { reason: 'branch_generation_changed' };
|
|
4239
|
-
}
|
|
4240
|
-
if (message.includes('already running; previews must not overlap')) {
|
|
4241
|
-
return { reason: 'preview_in_flight' };
|
|
4242
|
-
}
|
|
4243
|
-
if (message.includes('requires adaptiveResolution')) {
|
|
4244
|
-
return { reason: 'no_adaptive_resolution' };
|
|
4245
|
-
}
|
|
4246
|
-
return { reason: message };
|
|
4672
|
+
return { reason: previewStoppedReason(error) };
|
|
4247
4673
|
}
|
|
4248
4674
|
};
|
|
4249
4675
|
const effective = runPreview(plan.effectiveBudgetTokens);
|
|
@@ -4364,17 +4790,36 @@ export class AgentFramework {
|
|
|
4364
4790
|
// Non-fatal — usage tracking is best-effort
|
|
4365
4791
|
}
|
|
4366
4792
|
}
|
|
4793
|
+
/** An agent's persisted record, or null when it has none; throws when the store can't be read. */
|
|
4367
4794
|
readAgentRuntimeSettings(agentName) {
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
return stored ? { ...stored } : null;
|
|
4372
|
-
}
|
|
4373
|
-
catch {
|
|
4374
|
-
return null;
|
|
4375
|
-
}
|
|
4795
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
4796
|
+
const stored = data?.agentRuntimeSettings?.[agentName];
|
|
4797
|
+
return stored ? { ...stored } : null;
|
|
4376
4798
|
}
|
|
4377
4799
|
validatePersistedAgentRuntimeSettings(agentName, overrides) {
|
|
4800
|
+
// A malformed number is dropped, with a line that names the value's kind,
|
|
4801
|
+
// never its content. It could never apply: a budget, tail or pace kept
|
|
4802
|
+
// unapplied would be reported and written back as it is, and a marker
|
|
4803
|
+
// that can't be read isn't trusted, so the budget restores paced.
|
|
4804
|
+
const numbers = [
|
|
4805
|
+
['contextBudgetReachedTokens', 1],
|
|
4806
|
+
['contextBudgetTokens', 1],
|
|
4807
|
+
['tailTokens', 0],
|
|
4808
|
+
['transitionPaceTokens', 1],
|
|
4809
|
+
];
|
|
4810
|
+
for (const [key, least] of numbers) {
|
|
4811
|
+
const value = overrides[key];
|
|
4812
|
+
if (value === undefined || (Number.isSafeInteger(value) && value >= least))
|
|
4813
|
+
continue;
|
|
4814
|
+
const kind = typeof value === 'number' ? `a number that isn't a ${least > 0 ? 'positive' : 'non-negative'} safe integer`
|
|
4815
|
+
: value === null ? 'null'
|
|
4816
|
+
: Array.isArray(value) ? 'an array'
|
|
4817
|
+
: typeof value === 'object' ? 'an object'
|
|
4818
|
+
: `a ${typeof value}`;
|
|
4819
|
+
console.error(`[agent-settings] ignoring an invalid persisted ${key} for agent "${agentName}": ${kind}`);
|
|
4820
|
+
const { [key]: _ignored, ...rest } = overrides;
|
|
4821
|
+
overrides = rest;
|
|
4822
|
+
}
|
|
4378
4823
|
if (overrides.sameRoundThinkTextPolicy !== undefined &&
|
|
4379
4824
|
overrides.sameRoundThinkTextPolicy !== 'public' &&
|
|
4380
4825
|
overrides.sameRoundThinkTextPolicy !== 'private') {
|
|
@@ -4420,6 +4865,142 @@ export class AgentFramework {
|
|
|
4420
4865
|
state.toolResultInlineCaps = all;
|
|
4421
4866
|
this.store.setStateJson(FRAMEWORK_STATE_ID, state);
|
|
4422
4867
|
}
|
|
4868
|
+
/**
|
|
4869
|
+
* Apply an agent's persisted settings at boot, each on its own, the budget
|
|
4870
|
+
* last. A persisted setting never stops the agent booting, except a
|
|
4871
|
+
* think-text policy, which fails closed: an invalid one is refused before
|
|
4872
|
+
* this (validatePersistedAgentRuntimeSettings), and a valid one always
|
|
4873
|
+
* applies. A tail, pace or budget that can't be applied now (a tail or pace
|
|
4874
|
+
* on a strategy without live settings, a budget no longer above maxTokens,
|
|
4875
|
+
* a lower one restored paced on a strategy that can't prepare a smaller
|
|
4876
|
+
* window) is kept set but unapplied (Agent.keepRuntimeSettings), so a later
|
|
4877
|
+
* persisting write keeps it in the record.
|
|
4878
|
+
*
|
|
4879
|
+
* A budget below the recipe's restores as a paced descent from it, unless
|
|
4880
|
+
* the record carries its marker (`contextBudgetReachedTokens`, written when
|
|
4881
|
+
* a compile fit at it) and the preflight says it fits now: then it is
|
|
4882
|
+
* applied at once. The preflight plans the stored context as the context
|
|
4883
|
+
* manager will, with the restored tail and pace, so it sees what a restart
|
|
4884
|
+
* can change there: a new maxTokens (the response reserve) or strategy
|
|
4885
|
+
* configuration. It also sees a context that grew while the agent ran at
|
|
4886
|
+
* another budget after the record was written, through a persist: false
|
|
4887
|
+
* change. The system prompt, tools and injections are outside the context
|
|
4888
|
+
* manager's budget, so they don't enter it (context-manager #162). If a
|
|
4889
|
+
* compile at a budget applied at once this way is refused, the agent
|
|
4890
|
+
* restores it paced then (armed by Agent.restoreRuntimeSettings, run in
|
|
4891
|
+
* Agent.compileAtLiveBudgetNoting). A budget at or above the recipe's is
|
|
4892
|
+
* applied at once with or without the marker, as an increase always is.
|
|
4893
|
+
*
|
|
4894
|
+
* When a marked budget isn't applied at once or a setting can't be
|
|
4895
|
+
* applied, one line says why, what couldn't be applied, and the budget set
|
|
4896
|
+
* beside the one compiles plan at.
|
|
4897
|
+
*/
|
|
4898
|
+
restorePersistedRuntimeSettings(agentName, agent, overrides) {
|
|
4899
|
+
const messageOf = (error) => (error instanceof Error ? error.message : String(error));
|
|
4900
|
+
const { contextBudgetReachedTokens: reached, sameRoundThinkTextPolicy, ...settings } = overrides;
|
|
4901
|
+
if (sameRoundThinkTextPolicy !== undefined)
|
|
4902
|
+
agent.restoreRuntimeSettings({ sameRoundThinkTextPolicy });
|
|
4903
|
+
const unapplied = [];
|
|
4904
|
+
/** Restore one setting, or keep it set but unapplied; true if it applied. */
|
|
4905
|
+
const restore = (part) => {
|
|
4906
|
+
try {
|
|
4907
|
+
agent.restoreRuntimeSettings(part);
|
|
4908
|
+
return true;
|
|
4909
|
+
}
|
|
4910
|
+
catch (error) {
|
|
4911
|
+
agent.keepRuntimeSettings(part);
|
|
4912
|
+
unapplied.push(Object.entries(part).map(([key, value]) => `${key}=${value}`).join(', ') + ` (${messageOf(error)})`);
|
|
4913
|
+
return false;
|
|
4914
|
+
}
|
|
4915
|
+
};
|
|
4916
|
+
if (settings.tailTokens !== undefined)
|
|
4917
|
+
restore({ tailTokens: settings.tailTokens });
|
|
4918
|
+
if (settings.transitionPaceTokens !== undefined)
|
|
4919
|
+
restore({ transitionPaceTokens: settings.transitionPaceTokens });
|
|
4920
|
+
const budget = settings.contextBudgetTokens;
|
|
4921
|
+
let atOnce;
|
|
4922
|
+
let appliedAtOnce = false;
|
|
4923
|
+
if (budget !== undefined && reached === budget) {
|
|
4924
|
+
const patch = { contextBudgetTokens: budget, immediate: true };
|
|
4925
|
+
let why;
|
|
4926
|
+
try {
|
|
4927
|
+
const preview = this.previewRuntimeSettingsFor(agent, patch);
|
|
4928
|
+
if (!preview.available)
|
|
4929
|
+
why = `whether it fits now can't be checked (${preview.reason ?? 'no preview'})`;
|
|
4930
|
+
else if (preview.effective?.fits !== true)
|
|
4931
|
+
why = `the folded floor renders ${preview.effective?.finalTokens} tokens against it now`;
|
|
4932
|
+
}
|
|
4933
|
+
catch (error) {
|
|
4934
|
+
why = `whether it fits now can't be checked (${messageOf(error)})`;
|
|
4935
|
+
}
|
|
4936
|
+
if (why === undefined) {
|
|
4937
|
+
try {
|
|
4938
|
+
agent.restoreRuntimeSettings(patch, { provenBudget: budget });
|
|
4939
|
+
appliedAtOnce = true;
|
|
4940
|
+
}
|
|
4941
|
+
catch (error) {
|
|
4942
|
+
why = `applying it at once failed (${messageOf(error)}), though the preflight says it fits now`;
|
|
4943
|
+
}
|
|
4944
|
+
}
|
|
4945
|
+
if (why !== undefined)
|
|
4946
|
+
atOnce = `its record says a compile fit at contextBudgetTokens=${budget}, but ${why}`;
|
|
4947
|
+
}
|
|
4948
|
+
const budgetKept = budget !== undefined && !appliedAtOnce && !restore({ contextBudgetTokens: budget });
|
|
4949
|
+
if (atOnce === undefined && unapplied.length === 0)
|
|
4950
|
+
return;
|
|
4951
|
+
const now = agent.getRuntimeSettings();
|
|
4952
|
+
const said = [];
|
|
4953
|
+
if (atOnce !== undefined) {
|
|
4954
|
+
said.push(budgetKept ? atOnce : `${atOnce}; restored it ${now.transition === 'stable' ? 'without that proof' : 'paced instead'}`);
|
|
4955
|
+
}
|
|
4956
|
+
if (unapplied.length > 0) {
|
|
4957
|
+
said.push(`${unapplied.join(', ')} can't be applied now, so ${unapplied.length === 1 ? 'it stays' : 'they stay'} set but unapplied`);
|
|
4958
|
+
}
|
|
4959
|
+
console.error(`[agent-settings] ${agentName}: ${said.join('; ')}: contextBudgetTokens is ${now.contextBudgetTokens}, ` +
|
|
4960
|
+
`and compiles plan at ${now.effectiveContextBudgetTokens}${now.transition === 'stable' ? '' : ` (${now.transition})`}`);
|
|
4961
|
+
}
|
|
4962
|
+
/**
|
|
4963
|
+
* What persists for an agent: its overrides, and `contextBudgetReachedTokens`
|
|
4964
|
+
* when the agent's proof stands for the overridden budget: a compile fit at
|
|
4965
|
+
* it, or the boot applied it at once on the record's proof, and the live
|
|
4966
|
+
* budget hasn't changed since (context-manager #162). A
|
|
4967
|
+
* change of budget, immediate or not, carries none until a compile at it
|
|
4968
|
+
* fits; arming or cancelling a descent leaves the proof as it was.
|
|
4969
|
+
*/
|
|
4970
|
+
persistedRuntimeSettingsOf(agent) {
|
|
4971
|
+
const overrides = agent.getRuntimeSettingsOverrides();
|
|
4972
|
+
const proven = agent.getProvenContextBudgetTokens();
|
|
4973
|
+
return overrides.contextBudgetTokens !== undefined && proven === overrides.contextBudgetTokens
|
|
4974
|
+
? { ...overrides, contextBudgetReachedTokens: proven }
|
|
4975
|
+
: overrides;
|
|
4976
|
+
}
|
|
4977
|
+
/**
|
|
4978
|
+
* A compile at the agent's live budget came out differently from the last
|
|
4979
|
+
* (Agent.onContextBudgetCompiled). Keep the persisted record's
|
|
4980
|
+
* `contextBudgetReachedTokens` true to it: written when a compile fits at
|
|
4981
|
+
* the record's own budget, withdrawn when the context manager refuses that
|
|
4982
|
+
* budget. Nothing else in the record changes, and a budget other than the
|
|
4983
|
+
* record's (one applied with `persist: false`, or a descent's starting
|
|
4984
|
+
* budget) writes nothing. A record that can't be read, or written, is
|
|
4985
|
+
* logged, and the next compile at that budget tells it again.
|
|
4986
|
+
*/
|
|
4987
|
+
recordCompiledContextBudget(agentName, budget, fit) {
|
|
4988
|
+
try {
|
|
4989
|
+
const persisted = this.readAgentRuntimeSettings(agentName);
|
|
4990
|
+
if (!persisted || persisted.contextBudgetTokens !== budget)
|
|
4991
|
+
return true;
|
|
4992
|
+
if ((persisted.contextBudgetReachedTokens === budget) === fit)
|
|
4993
|
+
return true;
|
|
4994
|
+
const { contextBudgetReachedTokens: _previous, ...settings } = persisted;
|
|
4995
|
+
this.persistAgentRuntimeSettings(agentName, fit ? { ...settings, contextBudgetReachedTokens: budget } : settings);
|
|
4996
|
+
return true;
|
|
4997
|
+
}
|
|
4998
|
+
catch (error) {
|
|
4999
|
+
// The next compile at this budget tells it again.
|
|
5000
|
+
console.error(`[agent-settings] couldn't record ${agentName}'s compile at budget ${budget}:`, error);
|
|
5001
|
+
return false;
|
|
5002
|
+
}
|
|
5003
|
+
}
|
|
4423
5004
|
persistAgentRuntimeSettings(agentName, overrides) {
|
|
4424
5005
|
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
4425
5006
|
const state = (data && typeof data === 'object' ? data : {});
|
|
@@ -4591,18 +5172,32 @@ export class AgentFramework {
|
|
|
4591
5172
|
run.toolCallsCount += count;
|
|
4592
5173
|
run.lastActivity = Date.now();
|
|
4593
5174
|
}
|
|
5175
|
+
/**
|
|
5176
|
+
* A start dropped before its stream sends anything (its agent was replaced,
|
|
5177
|
+
* or stop() closed admission or ended the lifecycle the start began in)
|
|
5178
|
+
* ends the logical turn it was running. stop() ends every open turn
|
|
5179
|
+
* (openLogical), but a fresh start that awaited its batches' assembly
|
|
5180
|
+
* across a stop() reaches its routing after it and opens its turn. That
|
|
5181
|
+
* turn would then stay open, and the agent's first start after a restart
|
|
5182
|
+
* would continue the dropped turn's routing. The identity check leaves any
|
|
5183
|
+
* other turn open.
|
|
5184
|
+
*/
|
|
5185
|
+
endDroppedTurn(agentName, routing) {
|
|
5186
|
+
if (this.openLogical.get(agentName) === routing)
|
|
5187
|
+
this.openLogical.delete(agentName);
|
|
5188
|
+
}
|
|
4594
5189
|
/** A logical turn's end, for every agent: it closes the agent's open
|
|
4595
5190
|
* logical turn when it is the settling stream's own (openLogical), and
|
|
4596
5191
|
* settles an active ephemeral run. The run's tool-call count comes from
|
|
4597
5192
|
* its own counter — the single source of truth, which unlike a
|
|
4598
5193
|
* per-stream local survives budget restarts. */
|
|
4599
5194
|
settleAgent(agentName, outcome,
|
|
4600
|
-
/** The settling stream's logical turn, when that stream
|
|
4601
|
-
* agent: it ends here, so the agent's next fresh start
|
|
4602
|
-
*
|
|
5195
|
+
/** The settling stream's logical turn (TurnRouting), when that stream
|
|
5196
|
+
* still owns the agent: it ends here, so the agent's next fresh start
|
|
5197
|
+
* opens a new one. A stream that no longer owns the agent passes none. */
|
|
4603
5198
|
closes) {
|
|
4604
|
-
//
|
|
4605
|
-
// a newer turn
|
|
5199
|
+
// The identity check keeps a settle that carries an earlier turn's
|
|
5200
|
+
// object from closing a newer turn; keep it when editing call sites.
|
|
4606
5201
|
if (closes && this.openLogical.get(agentName) === closes)
|
|
4607
5202
|
this.openLogical.delete(agentName);
|
|
4608
5203
|
const run = this.ephemeralRuns.get(agentName);
|
|
@@ -4611,7 +5206,10 @@ export class AgentFramework {
|
|
|
4611
5206
|
run.lastActivity = Date.now();
|
|
4612
5207
|
if (outcome.stopReason === 'exhausted') {
|
|
4613
5208
|
// The caller may log or relay the message, which can echo the whole request.
|
|
4614
|
-
|
|
5209
|
+
const message = boundFailureText(outcome.error ?? 'Unknown error');
|
|
5210
|
+
run.settle.reject(outcome.statedWaitMs === undefined
|
|
5211
|
+
? new Error(message)
|
|
5212
|
+
: new ProviderWaitNotSleptError(message, outcome.statedWaitMs));
|
|
4615
5213
|
return;
|
|
4616
5214
|
}
|
|
4617
5215
|
run.settle.resolve({
|
|
@@ -4626,6 +5224,9 @@ export class AgentFramework {
|
|
|
4626
5224
|
* The agent is temporarily registered, inference is triggered, and the
|
|
4627
5225
|
* framework drives the stream (emitting traces, logging, dispatching tools).
|
|
4628
5226
|
* Returns the agent's speech output when it finishes (no more tool calls).
|
|
5227
|
+
* Rejects with ProviderWaitNotSleptError when the run ends rather than sleep
|
|
5228
|
+
* a retryable failure's stated wait, and with a plain Error for any other
|
|
5229
|
+
* ending.
|
|
4629
5230
|
*
|
|
4630
5231
|
* The caller provides a pre-created agent + contextManager (from createEphemeralAgent).
|
|
4631
5232
|
* The task message should already be in the context manager.
|
|
@@ -5150,7 +5751,7 @@ export class AgentFramework {
|
|
|
5150
5751
|
const serverId = this.channelRegistry?.getChannelServerId(incoming.channelId);
|
|
5151
5752
|
const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
|
|
5152
5753
|
if (!server) {
|
|
5153
|
-
console.error(`[inference-refusal] reaction skipped: no MCPL server for locus
|
|
5754
|
+
console.error(`[inference-refusal] reaction skipped: no MCPL server for locus ${logValue(incoming.channelId)} (agent=${agentName})`);
|
|
5154
5755
|
return;
|
|
5155
5756
|
}
|
|
5156
5757
|
await server.sendToolsCall('add_reaction', {
|
|
@@ -5200,23 +5801,37 @@ export class AgentFramework {
|
|
|
5200
5801
|
}
|
|
5201
5802
|
if (idx < 0)
|
|
5202
5803
|
return null;
|
|
5804
|
+
// The newest message can be the last shard of a body (a document stored
|
|
5805
|
+
// in shards). context-manager removes a shard only with the rest of its
|
|
5806
|
+
// body, so the rewind takes the whole body.
|
|
5807
|
+
const first = bodyRangeIn(all, idx).from;
|
|
5808
|
+
const members = all.slice(first, idx + 1);
|
|
5203
5809
|
const msg = all[idx];
|
|
5204
5810
|
const md = (msg.metadata ?? {});
|
|
5205
|
-
const content = Array.isArray(
|
|
5811
|
+
const content = members.flatMap((m) => (Array.isArray(m.content) ? m.content : []));
|
|
5206
5812
|
const toolBlocks = content.filter((b) => typeOf(b) === 'tool_result');
|
|
5207
5813
|
const images = content.filter((b) => typeOf(b) === 'image').length;
|
|
5208
5814
|
const textLen = content
|
|
5209
5815
|
.filter((b) => typeOf(b) === 'text')
|
|
5210
5816
|
.reduce((n, b) => n + String(b.text ?? '').length, 0);
|
|
5211
|
-
// Determine the complete exchange to remove.
|
|
5212
|
-
const removedIds =
|
|
5817
|
+
// Determine the complete exchange to remove, newest first.
|
|
5818
|
+
const removedIds = members.map((m) => m.id).reverse();
|
|
5819
|
+
// Where the exchange starts: this body, or the tool_use turn paired with it.
|
|
5820
|
+
let from = first;
|
|
5213
5821
|
let kind;
|
|
5214
5822
|
let descriptor;
|
|
5215
5823
|
if (toolBlocks.length > 0) {
|
|
5216
5824
|
kind = 'tool';
|
|
5217
|
-
// Pair the tool_result with the tool_use assistant turn right before
|
|
5218
|
-
|
|
5219
|
-
|
|
5825
|
+
// Pair the tool_result with the tool_use assistant turn right before
|
|
5826
|
+
// it, the whole of it: a turn stored as a body carries its tool_use on
|
|
5827
|
+
// its first shard, not its last.
|
|
5828
|
+
if (first - 1 >= 0) {
|
|
5829
|
+
const turn = bodyRangeIn(all, first - 1);
|
|
5830
|
+
const turnMembers = all.slice(turn.from, turn.to + 1);
|
|
5831
|
+
if (turnMembers.some((m) => hasBlock(m, 'tool_use'))) {
|
|
5832
|
+
from = turn.from;
|
|
5833
|
+
removedIds.push(...turnMembers.map((m) => m.id).reverse());
|
|
5834
|
+
}
|
|
5220
5835
|
}
|
|
5221
5836
|
const sz = toolBlocks.reduce((n, b) => n + String(b.content ?? '').length, 0);
|
|
5222
5837
|
descriptor = `a tool exchange (~${Math.max(1, Math.round(sz / 1024))}KB` +
|
|
@@ -5235,8 +5850,8 @@ export class AgentFramework {
|
|
|
5235
5850
|
const discordRef = md.messageId && md.channelId
|
|
5236
5851
|
? { channelId: String(md.channelId), messageId: String(md.messageId) }
|
|
5237
5852
|
: undefined;
|
|
5238
|
-
for
|
|
5239
|
-
|
|
5853
|
+
// One removal for the whole exchange, so it is never half applied.
|
|
5854
|
+
removeStoredMessages(cm, all[from], msg);
|
|
5240
5855
|
return { kind, descriptor, removedIds, discordRef };
|
|
5241
5856
|
}
|
|
5242
5857
|
/**
|
|
@@ -5408,7 +6023,11 @@ export class AgentFramework {
|
|
|
5408
6023
|
* stated retry-after, kept across restarts) for one `model` or all, as an
|
|
5409
6024
|
* explicit, recorded operator override.
|
|
5410
6025
|
*/
|
|
5411
|
-
|
|
6026
|
+
handleHostCommand(serverId, params) {
|
|
6027
|
+
return this.trackOperatorOperation(`host/command:${String(params.command)}`, () => this.runHostCommand(serverId, params));
|
|
6028
|
+
}
|
|
6029
|
+
/** handleHostCommand's work, run where stop() can wait for it. */
|
|
6030
|
+
async runHostCommand(serverId, params) {
|
|
5412
6031
|
if (params.command !== 'undo' &&
|
|
5413
6032
|
params.command !== 'hide' &&
|
|
5414
6033
|
params.command !== 'unstick' &&
|
|
@@ -5531,26 +6150,80 @@ export class AgentFramework {
|
|
|
5531
6150
|
if (agent.state.status !== 'idle') {
|
|
5532
6151
|
return { ok: false, error: `Cannot hide while agent is ${agent.state.status}` };
|
|
5533
6152
|
}
|
|
6153
|
+
// A surgery switches or forks the branch while it holds the store, so
|
|
6154
|
+
// a removal now would land on the branch it's leaving, or meet a
|
|
6155
|
+
// manager mid-switch: refused as busy, as the other operator calls are.
|
|
6156
|
+
if (this.surgeryHold) {
|
|
6157
|
+
return {
|
|
6158
|
+
ok: false,
|
|
6159
|
+
error: `Cannot hide: a live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} holds the store — retry when it completes`,
|
|
6160
|
+
};
|
|
6161
|
+
}
|
|
5534
6162
|
if (!params.fromMessageId) {
|
|
5535
6163
|
return { ok: false, error: 'hide: fromMessageId is required' };
|
|
5536
6164
|
}
|
|
5537
6165
|
const cm = agent.getContextManager();
|
|
5538
6166
|
const all = cm.getAllMessages();
|
|
5539
|
-
|
|
5540
|
-
//
|
|
6167
|
+
// Only a message that carries a Discord id matches: a missing one
|
|
6168
|
+
// would otherwise read as the text "undefined".
|
|
6169
|
+
const discordIdOf = (m) => {
|
|
6170
|
+
const id = m.metadata?.messageId;
|
|
6171
|
+
return id === undefined || id === null || id === '' ? undefined : String(id);
|
|
6172
|
+
};
|
|
6173
|
+
const byDiscordId = (did) => all.findIndex((m) => discordIdOf(m) === String(did));
|
|
6174
|
+
/** Runs in stored order, those that overlap or touch made one. */
|
|
6175
|
+
const merged = (runs) => {
|
|
6176
|
+
const out = [];
|
|
6177
|
+
for (const { from, to } of [...runs].sort((a, b) => a.from - b.from)) {
|
|
6178
|
+
const last = out[out.length - 1];
|
|
6179
|
+
if (last && from <= last.to + 1)
|
|
6180
|
+
last.to = Math.max(last.to, to);
|
|
6181
|
+
else
|
|
6182
|
+
out.push({ from, to });
|
|
6183
|
+
}
|
|
6184
|
+
return out;
|
|
6185
|
+
};
|
|
6186
|
+
// A message stored as a body (a long one, in shards) goes whole, and so
|
|
6187
|
+
// does every copy of it: a write cut short leaves a partial body before
|
|
6188
|
+
// its retry, both carrying the Discord id.
|
|
6189
|
+
const copiesOf = (did) => {
|
|
6190
|
+
const runs = [];
|
|
6191
|
+
all.forEach((m, i) => { if (discordIdOf(m) === String(did))
|
|
6192
|
+
runs.push(bodyRangeIn(all, i)); });
|
|
6193
|
+
return runs;
|
|
6194
|
+
};
|
|
6195
|
+
// Collect Discord refs of every message in the runs that carries one,
|
|
5541
6196
|
// so the surface can mark them. channelId here is whatever the ingest
|
|
5542
6197
|
// path stored (raw id or "discord:guild:channel"); the surface
|
|
5543
6198
|
// normalizes it.
|
|
5544
|
-
|
|
6199
|
+
// Each Discord message once, though every shard of a body carries it.
|
|
6200
|
+
const refsIn = (runs) => {
|
|
5545
6201
|
const refs = [];
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
6202
|
+
const seen = new Set();
|
|
6203
|
+
for (const { from, to } of runs) {
|
|
6204
|
+
for (let i = from; i <= to; i++) {
|
|
6205
|
+
const md = all[i].metadata;
|
|
6206
|
+
if (!md?.messageId || !md?.channelId)
|
|
6207
|
+
continue;
|
|
6208
|
+
const key = `${String(md.channelId)}\u0000${String(md.messageId)}`;
|
|
6209
|
+
if (seen.has(key))
|
|
6210
|
+
continue;
|
|
6211
|
+
seen.add(key);
|
|
5549
6212
|
refs.push({ channelId: String(md.channelId), messageId: String(md.messageId) });
|
|
5550
6213
|
}
|
|
5551
6214
|
}
|
|
5552
6215
|
return refs;
|
|
5553
6216
|
};
|
|
6217
|
+
// How many stored messages the runs hold, a body (a long message, in
|
|
6218
|
+
// shards) counting once.
|
|
6219
|
+
const messagesIn = (runs) => {
|
|
6220
|
+
let count = 0;
|
|
6221
|
+
for (const { from, to } of runs) {
|
|
6222
|
+
for (let i = from; i <= to; i = bodyRangeIn(all, i).to + 1)
|
|
6223
|
+
count++;
|
|
6224
|
+
}
|
|
6225
|
+
return count;
|
|
6226
|
+
};
|
|
5554
6227
|
const fromIdx = byDiscordId(params.fromMessageId);
|
|
5555
6228
|
if (fromIdx < 0) {
|
|
5556
6229
|
return {
|
|
@@ -5564,11 +6237,24 @@ export class AgentFramework {
|
|
|
5564
6237
|
if (toIdx < 0) {
|
|
5565
6238
|
return { ok: false, error: `Message ${params.toMessageId} is not an addressable message in context.` };
|
|
5566
6239
|
}
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
6240
|
+
// What's between the two messages, after the earlier one began and
|
|
6241
|
+
// before the later one began: from the earlier one's first stored
|
|
6242
|
+
// copy through the later one's first, each whole. Each end is every
|
|
6243
|
+
// copy of it besides, as the single form takes, so a copy stored
|
|
6244
|
+
// outside that span (a write cut short and retried later) is taken
|
|
6245
|
+
// on its own, and nothing else stored after the later one began is
|
|
6246
|
+
// taken. Offline recovery's range is the same.
|
|
6247
|
+
const span = {
|
|
6248
|
+
from: bodyRangeIn(all, Math.min(fromIdx, toIdx)).from,
|
|
6249
|
+
to: bodyRangeIn(all, Math.max(fromIdx, toIdx)).to,
|
|
6250
|
+
};
|
|
6251
|
+
const runs = merged([span, ...copiesOf(params.fromMessageId), ...copiesOf(params.toMessageId)]);
|
|
6252
|
+
const refs = refsIn(runs);
|
|
6253
|
+
const hidden = messagesIn(runs);
|
|
6254
|
+
const rangeIds = runs.flatMap(({ from, to }) => all.slice(from, to + 1).map((m) => String(m.id)));
|
|
6255
|
+
for (const { from, to } of runs)
|
|
6256
|
+
removeStoredMessages(cm, all[from], all[to]);
|
|
6257
|
+
console.error(`[host-command] hide agent=${agentName} range removed=${hidden} ` +
|
|
5572
6258
|
`(${params.fromMessageId}..${params.toMessageId}) by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`);
|
|
5573
6259
|
const loggedRange = capIds(rangeIds);
|
|
5574
6260
|
this.recordOperatorAction({
|
|
@@ -5578,33 +6264,44 @@ export class AgentFramework {
|
|
|
5578
6264
|
params: { fromMessageId: params.fromMessageId, toMessageId: params.toMessageId },
|
|
5579
6265
|
result: {
|
|
5580
6266
|
branch: this.store.currentBranch().name,
|
|
5581
|
-
hidden
|
|
6267
|
+
hidden,
|
|
5582
6268
|
removedIds: loggedRange.ids,
|
|
5583
6269
|
...(loggedRange.truncated ? { removedIdsTruncated: true } : {}),
|
|
5584
6270
|
},
|
|
5585
6271
|
});
|
|
5586
6272
|
return {
|
|
5587
6273
|
ok: true,
|
|
5588
|
-
hidden
|
|
6274
|
+
hidden,
|
|
5589
6275
|
hiddenRefs: refs,
|
|
5590
6276
|
lastVisible: await this.lastVisiblePreview(agentName),
|
|
5591
6277
|
};
|
|
5592
6278
|
}
|
|
5593
|
-
const
|
|
5594
|
-
const
|
|
5595
|
-
|
|
5596
|
-
|
|
6279
|
+
const runs = merged(copiesOf(params.fromMessageId));
|
|
6280
|
+
const refs = refsIn(runs);
|
|
6281
|
+
const hidden = messagesIn(runs);
|
|
6282
|
+
const hiddenIds = runs.flatMap(({ from, to }) => all.slice(from, to + 1).map((m) => String(m.id)));
|
|
6283
|
+
for (const { from, to } of runs)
|
|
6284
|
+
removeStoredMessages(cm, all[from], all[to]);
|
|
6285
|
+
console.error(`[host-command] hide agent=${agentName} removed=${hidden} (${params.fromMessageId}) ` +
|
|
5597
6286
|
`by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`);
|
|
6287
|
+
// Every shard of every copy goes, so the ids are bounded as the
|
|
6288
|
+
// range form's are.
|
|
6289
|
+
const loggedIds = capIds(hiddenIds);
|
|
5598
6290
|
this.recordOperatorAction({
|
|
5599
6291
|
kind: 'hide',
|
|
5600
6292
|
agent: agentName,
|
|
5601
6293
|
requester: hostCommandRequester(serverId, params),
|
|
5602
6294
|
params: { fromMessageId: params.fromMessageId },
|
|
5603
|
-
result: {
|
|
6295
|
+
result: {
|
|
6296
|
+
branch: this.store.currentBranch().name,
|
|
6297
|
+
hidden,
|
|
6298
|
+
removedIds: loggedIds.ids,
|
|
6299
|
+
...(loggedIds.truncated ? { removedIdsTruncated: true } : {}),
|
|
6300
|
+
},
|
|
5604
6301
|
});
|
|
5605
6302
|
return {
|
|
5606
6303
|
ok: true,
|
|
5607
|
-
hidden
|
|
6304
|
+
hidden,
|
|
5608
6305
|
hiddenRefs: refs,
|
|
5609
6306
|
lastVisible: await this.lastVisiblePreview(agentName),
|
|
5610
6307
|
};
|
|
@@ -5658,7 +6355,7 @@ export class AgentFramework {
|
|
|
5658
6355
|
let undone = 0;
|
|
5659
6356
|
try {
|
|
5660
6357
|
for (let i = 0; i < requested; i++) {
|
|
5661
|
-
const r = this.
|
|
6358
|
+
const r = this.runUndoLastTurn(agentName, hostCommandRequester(serverId, params));
|
|
5662
6359
|
if (!r.undone)
|
|
5663
6360
|
break;
|
|
5664
6361
|
undone++;
|
|
@@ -5669,7 +6366,7 @@ export class AgentFramework {
|
|
|
5669
6366
|
// including any turns already undone before the failure.
|
|
5670
6367
|
const msg = error instanceof Error ? error.message : String(error);
|
|
5671
6368
|
if (undone === 0)
|
|
5672
|
-
return { ok: false, error: msg };
|
|
6369
|
+
return { ok: false, error: msg, ...(error instanceof OperatorActionError ? { code: error.code } : {}) };
|
|
5673
6370
|
console.error(`[host-command] undo partially failed after ${undone}/${requested}: ${msg}`);
|
|
5674
6371
|
}
|
|
5675
6372
|
console.error(`[host-command] undo agent=${agentName} requested=${requested} undone=${undone}` +
|
|
@@ -5938,8 +6635,45 @@ export class AgentFramework {
|
|
|
5938
6635
|
return result.entries;
|
|
5939
6636
|
}
|
|
5940
6637
|
// ==========================================================================
|
|
5941
|
-
// Undo / Redo
|
|
6638
|
+
// Branch switch, Undo / Redo
|
|
5942
6639
|
// ==========================================================================
|
|
6640
|
+
/**
|
|
6641
|
+
* Switch the store to an existing branch, start the delivery of the
|
|
6642
|
+
* Discord awareness marks the move changes (startDiscordAwarenessDelivery),
|
|
6643
|
+
* and re-materialize the config mount from it. Resolves once the mount has
|
|
6644
|
+
* run; if it fails, it rejects with the store already moved. The marks'
|
|
6645
|
+
* delivery isn't awaited, and its failure doesn't fail the switch. The
|
|
6646
|
+
* store has moved by the time the call returns, before its promise
|
|
6647
|
+
* settles, and a refusal moves nothing. An operator's operation: refused
|
|
6648
|
+
* once stop() has begun, and waited for by stop() while it runs.
|
|
6649
|
+
*
|
|
6650
|
+
* The branch moves under every agent sharing the store, so the switch
|
|
6651
|
+
* reserves the store as a surgery does: every agent must be idle, or it is
|
|
6652
|
+
* refused (OperatorActionError 'agent-busy'), and no turn starts until the
|
|
6653
|
+
* switch is done.
|
|
6654
|
+
*/
|
|
6655
|
+
async switchBranch(name) {
|
|
6656
|
+
this.refuseOperatorAfterStop('switch branch');
|
|
6657
|
+
const release = this.reserveStoreForSurgery('switch branch', 'every agent');
|
|
6658
|
+
try {
|
|
6659
|
+
// A maintenance tick already running finishes before the switch.
|
|
6660
|
+
if (this.maintenanceRunning())
|
|
6661
|
+
await this.settleMaintenancePass();
|
|
6662
|
+
this.store.switchBranch(name);
|
|
6663
|
+
// Reversible Discord awareness markers are part of the branch
|
|
6664
|
+
// projection. Their delivery starts behind the data-plane gate, which
|
|
6665
|
+
// goes up before the store is released, and the switch doesn't wait
|
|
6666
|
+
// for Discord.
|
|
6667
|
+
this.startDiscordAwarenessDelivery('branch switch');
|
|
6668
|
+
const ws = this.moduleRegistry.getModule('workspace');
|
|
6669
|
+
if (ws && 'materializeMount' in ws) {
|
|
6670
|
+
await ws.materializeMount('_config');
|
|
6671
|
+
}
|
|
6672
|
+
}
|
|
6673
|
+
finally {
|
|
6674
|
+
release();
|
|
6675
|
+
}
|
|
6676
|
+
}
|
|
5943
6677
|
/**
|
|
5944
6678
|
* Undo the last inference turn for an agent.
|
|
5945
6679
|
*
|
|
@@ -5951,6 +6685,12 @@ export class AgentFramework {
|
|
|
5951
6685
|
* Returns the checkpoint that was undone, or null if nothing to undo.
|
|
5952
6686
|
*/
|
|
5953
6687
|
undoLastTurn(agentName, requester) {
|
|
6688
|
+
this.refuseOperatorAfterStop('undo');
|
|
6689
|
+
return this.runUndoLastTurn(agentName, requester);
|
|
6690
|
+
}
|
|
6691
|
+
/** undoLastTurn's work, for a host/command undo already admitted: a stop()
|
|
6692
|
+
* begun by a trace of one of its turns doesn't refuse the next. */
|
|
6693
|
+
runUndoLastTurn(agentName, requester) {
|
|
5954
6694
|
const agent = this.agents.get(agentName);
|
|
5955
6695
|
if (!agent) {
|
|
5956
6696
|
throw new Error(`Unknown agent: ${agentName}`);
|
|
@@ -5958,6 +6698,10 @@ export class AgentFramework {
|
|
|
5958
6698
|
if (agent.state.status !== 'idle') {
|
|
5959
6699
|
throw new Error(`Cannot undo while agent is ${agent.state.status}`);
|
|
5960
6700
|
}
|
|
6701
|
+
// A surgery or a branch switch holds the store while it awaits its steps.
|
|
6702
|
+
if (this.surgeryHold) {
|
|
6703
|
+
throw new OperatorActionError('agent-busy', `Cannot undo: the store is under live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName}`);
|
|
6704
|
+
}
|
|
5961
6705
|
const checkpoints = this.getTurnCheckpoints(agentName);
|
|
5962
6706
|
if (checkpoints.length === 0) {
|
|
5963
6707
|
return { undone: false };
|
|
@@ -5968,11 +6712,7 @@ export class AgentFramework {
|
|
|
5968
6712
|
const undoBranchName = `undo/${agentName}/${checkpoint.turnIndex}-${Date.now()}`;
|
|
5969
6713
|
this.store.createBranchAt(undoBranchName, currentBranch.name, checkpoint.sequenceBefore);
|
|
5970
6714
|
this.store.switchBranch(undoBranchName);
|
|
5971
|
-
|
|
5972
|
-
const wsUndo = this.moduleRegistry.getModule('workspace');
|
|
5973
|
-
if (wsUndo && 'materializeMount' in wsUndo) {
|
|
5974
|
-
wsUndo.materializeMount('_config').catch(() => { });
|
|
5975
|
-
}
|
|
6715
|
+
this.materializeConfigMountAfterBranchSwitch();
|
|
5976
6716
|
// Push onto redo stack
|
|
5977
6717
|
let redoStack = this.redoStacks.get(agentName);
|
|
5978
6718
|
if (!redoStack) {
|
|
@@ -6008,6 +6748,7 @@ export class AgentFramework {
|
|
|
6008
6748
|
* Returns false if there's nothing to redo.
|
|
6009
6749
|
*/
|
|
6010
6750
|
redo(agentName, requester) {
|
|
6751
|
+
this.refuseOperatorAfterStop('redo');
|
|
6011
6752
|
const agent = this.agents.get(agentName);
|
|
6012
6753
|
if (!agent) {
|
|
6013
6754
|
throw new Error(`Unknown agent: ${agentName}`);
|
|
@@ -6015,6 +6756,10 @@ export class AgentFramework {
|
|
|
6015
6756
|
if (agent.state.status !== 'idle') {
|
|
6016
6757
|
throw new Error(`Cannot redo while agent is ${agent.state.status}`);
|
|
6017
6758
|
}
|
|
6759
|
+
// A surgery or a branch switch holds the store while it awaits its steps.
|
|
6760
|
+
if (this.surgeryHold) {
|
|
6761
|
+
throw new OperatorActionError('agent-busy', `Cannot redo: the store is under live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName}`);
|
|
6762
|
+
}
|
|
6018
6763
|
const redoStack = this.redoStacks.get(agentName);
|
|
6019
6764
|
if (!redoStack || redoStack.length === 0) {
|
|
6020
6765
|
return { redone: false };
|
|
@@ -6022,11 +6767,7 @@ export class AgentFramework {
|
|
|
6022
6767
|
const { branchName, checkpoint } = redoStack.pop();
|
|
6023
6768
|
const currentBranch = this.store.currentBranch();
|
|
6024
6769
|
this.store.switchBranch(branchName);
|
|
6025
|
-
|
|
6026
|
-
const wsRedo = this.moduleRegistry.getModule('workspace');
|
|
6027
|
-
if (wsRedo && 'materializeMount' in wsRedo) {
|
|
6028
|
-
wsRedo.materializeMount('_config').catch(() => { });
|
|
6029
|
-
}
|
|
6770
|
+
this.materializeConfigMountAfterBranchSwitch();
|
|
6030
6771
|
// Restore the checkpoint
|
|
6031
6772
|
const checkpoints = this.getTurnCheckpoints(agentName);
|
|
6032
6773
|
checkpoints.push(checkpoint);
|
|
@@ -6138,11 +6879,22 @@ export class AgentFramework {
|
|
|
6138
6879
|
// arrivals during the awaited switch (see surgeryHold).
|
|
6139
6880
|
const hold = { verb, agentName, since: Date.now() };
|
|
6140
6881
|
this.surgeryHold = hold;
|
|
6882
|
+
// Maintenance calls parked at a provider gate wake, find the hold and are
|
|
6883
|
+
// withdrawn, so the wait for running maintenance can't wait on them.
|
|
6884
|
+
for (const gate of this.providerGates.values())
|
|
6885
|
+
for (const resolve of gate.auxiliaryWaiters.splice(0))
|
|
6886
|
+
resolve();
|
|
6887
|
+
// stop() waits for the release (see stopOwned): a surgery admitted before
|
|
6888
|
+
// stop() began finishes, flush included, before the store closes.
|
|
6889
|
+
let settle;
|
|
6890
|
+
void this.trackOperatorOperation(`surgery:${verb}`, () => new Promise((resolve) => { settle = resolve; }));
|
|
6141
6891
|
let released = false;
|
|
6142
6892
|
return () => {
|
|
6143
6893
|
if (released)
|
|
6144
6894
|
return;
|
|
6145
6895
|
released = true;
|
|
6896
|
+
// The release runs to its end before stop() can go on.
|
|
6897
|
+
settle();
|
|
6146
6898
|
if (this.surgeryHold === hold)
|
|
6147
6899
|
this.surgeryHold = null;
|
|
6148
6900
|
for (const [name, token] of reserved) {
|
|
@@ -6205,6 +6957,7 @@ export class AgentFramework {
|
|
|
6205
6957
|
* agent must be idle; nothing is queued.
|
|
6206
6958
|
*/
|
|
6207
6959
|
async rollbackToMessage(agentName, opts) {
|
|
6960
|
+
this.refuseOperatorAfterStop('roll back');
|
|
6208
6961
|
const logBase = {
|
|
6209
6962
|
kind: 'rollback',
|
|
6210
6963
|
agent: agentName,
|
|
@@ -6247,6 +7000,9 @@ export class AgentFramework {
|
|
|
6247
7000
|
let branchName;
|
|
6248
7001
|
let markers;
|
|
6249
7002
|
try {
|
|
7003
|
+
// A maintenance tick already running finishes before the switch.
|
|
7004
|
+
if (this.maintenanceRunning())
|
|
7005
|
+
await this.settleMaintenancePass();
|
|
6250
7006
|
// Prepare the external side effect before switching Chronicle. If the
|
|
6251
7007
|
// process dies after the switch but before activate(), startup
|
|
6252
7008
|
// promotes this batch by matching targetBranch to the active branch.
|
|
@@ -6333,6 +7089,7 @@ export class AgentFramework {
|
|
|
6333
7089
|
* entered if that matters.
|
|
6334
7090
|
*/
|
|
6335
7091
|
async suppressMessages(agentName, opts) {
|
|
7092
|
+
this.refuseOperatorAfterStop('suppress');
|
|
6336
7093
|
const requestedIds = [...new Set(opts.messageIds.map(String))];
|
|
6337
7094
|
const logBase = {
|
|
6338
7095
|
kind: 'suppress',
|
|
@@ -6384,6 +7141,9 @@ export class AgentFramework {
|
|
|
6384
7141
|
let createdBranch;
|
|
6385
7142
|
let markers;
|
|
6386
7143
|
try {
|
|
7144
|
+
// A maintenance tick already running finishes before the fork.
|
|
7145
|
+
if (this.maintenanceRunning())
|
|
7146
|
+
await this.settleMaintenancePass();
|
|
6387
7147
|
const markerBatch = this.discordAwarenessOutbox?.prepare({
|
|
6388
7148
|
agentName,
|
|
6389
7149
|
sourceBranch,
|
|
@@ -6539,9 +7299,9 @@ export class AgentFramework {
|
|
|
6539
7299
|
* undo or redo) without the caller awaiting it. No operator response waits
|
|
6540
7300
|
* for Discord to accept marks: the MCPL data-plane gate alone holds channel
|
|
6541
7301
|
* traffic that arrives after it until they are attempted.
|
|
6542
|
-
*
|
|
6543
|
-
*
|
|
6544
|
-
*
|
|
7302
|
+
* The sync's body installs that gate synchronously, before its first
|
|
7303
|
+
* await, so calling this while a surgery still holds the store puts the
|
|
7304
|
+
* gate up before the store is released. With no drain running, the
|
|
6545
7305
|
* drain starts in the same synchronous run (the first reaction request can
|
|
6546
7306
|
* be written before this returns); with one running, this generation's pass
|
|
6547
7307
|
* follows it (drainForGeneration). Either way delivery continues
|
|
@@ -6552,10 +7312,20 @@ export class AgentFramework {
|
|
|
6552
7312
|
* (queued when a surgery began, or arriving during its switch or fork): the
|
|
6553
7313
|
* surgery's store reservation, which used to hold such a turn through the
|
|
6554
7314
|
* drain, now ends once the gate is up. A failure is accounted by the gate
|
|
6555
|
-
* itself: the current generation's failure recycles the connections,
|
|
6556
|
-
*
|
|
6557
|
-
*
|
|
6558
|
-
*
|
|
7315
|
+
* itself: the current generation's failure recycles the connections (or,
|
|
7316
|
+
* once the host is stopping, releases the gate instead), and a superseded
|
|
7317
|
+
* one leaves that to the newer generation, whose own pass meets the same
|
|
7318
|
+
* ledger. Here it is logged and raised as an ops alert, and the next sync,
|
|
7319
|
+
* reconnect or start delivers what is still pending.
|
|
7320
|
+
*
|
|
7321
|
+
* Like a server's drain, the delivery runs apart from the operation that
|
|
7322
|
+
* started it: it isn't an operator's operation, so stop() doesn't wait for
|
|
7323
|
+
* it. A drain already running when stop() begins runs on while the
|
|
7324
|
+
* connections close, and what it can't deliver stays pending in the
|
|
7325
|
+
* durable outbox. Once stop() has begun, none is started, since there's
|
|
7326
|
+
* no point opening a drain that the teardown is about to cut off: the
|
|
7327
|
+
* marks stay pending, and the next start's barrier reconciles the outbox
|
|
7328
|
+
* with the branch and delivers them.
|
|
6559
7329
|
*
|
|
6560
7330
|
* @param after What moved the branch, for the log and the alert
|
|
6561
7331
|
* ("rollback", "branch switch", "undo" …).
|
|
@@ -6565,20 +7335,25 @@ export class AgentFramework {
|
|
|
6565
7335
|
startDiscordAwarenessDelivery(after, agentName = 'framework') {
|
|
6566
7336
|
if (!this.discordAwarenessOutbox)
|
|
6567
7337
|
return;
|
|
6568
|
-
this.
|
|
7338
|
+
if (this.stopHasBegun()) {
|
|
7339
|
+
console.error(`[discord-awareness] delivery after ${after} agent=${agentName} not started: ` +
|
|
7340
|
+
'the framework is stopping; the next start delivers what is pending');
|
|
7341
|
+
return;
|
|
7342
|
+
}
|
|
7343
|
+
this.runDiscordAwarenessSync().catch((error) => {
|
|
6569
7344
|
const detail = error instanceof Error ? error.message : String(error);
|
|
6570
7345
|
console.error(`[discord-awareness] delivery after ${after} agent=${agentName} failed: ${detail}`);
|
|
6571
7346
|
this.opsAlert('discord-awareness-delivery', agentName, `Awareness mark delivery after ${after} failed: ${detail}`);
|
|
6572
7347
|
});
|
|
6573
7348
|
}
|
|
6574
|
-
/**
|
|
6575
|
-
*
|
|
7349
|
+
/** Start config file IO without holding the caller or a surgery's store
|
|
7350
|
+
* reservation. The gate picks it up by mtime; stop() still waits for it. */
|
|
6576
7351
|
materializeConfigMountAfterBranchSwitch() {
|
|
6577
7352
|
const ws = this.moduleRegistry.getModule('workspace');
|
|
6578
7353
|
if (ws && 'materializeMount' in ws) {
|
|
6579
|
-
ws
|
|
6580
|
-
.
|
|
6581
|
-
|
|
7354
|
+
void this.trackOperatorOperation('config:materialize', () => ws.materializeMount('_config')).catch((error) => {
|
|
7355
|
+
console.error('[workspace] re-materializing the config mount after a branch switch failed:', error instanceof Error ? error.message : error);
|
|
7356
|
+
});
|
|
6582
7357
|
}
|
|
6583
7358
|
}
|
|
6584
7359
|
/**
|
|
@@ -6737,10 +7512,18 @@ export class AgentFramework {
|
|
|
6737
7512
|
this.adoptToolResultGuard(agent);
|
|
6738
7513
|
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
6739
7514
|
this.sharedSlotAgents.add(agent);
|
|
6740
|
-
|
|
7515
|
+
let restoredSettings = null;
|
|
7516
|
+
try {
|
|
7517
|
+
restoredSettings = this.readAgentRuntimeSettings(config.name);
|
|
7518
|
+
}
|
|
7519
|
+
catch (error) {
|
|
7520
|
+
console.error(`[agent-settings] ${config.name}: its persisted settings can't be read ` +
|
|
7521
|
+
`(${error instanceof Error ? error.message : String(error)}); it boots at the recipe's`);
|
|
7522
|
+
}
|
|
6741
7523
|
if (restoredSettings) {
|
|
6742
|
-
|
|
7524
|
+
this.restorePersistedRuntimeSettings(config.name, agent, this.validatePersistedAgentRuntimeSettings(config.name, restoredSettings));
|
|
6743
7525
|
}
|
|
7526
|
+
agent.onContextBudgetCompiled((budget, fit) => this.recordCompiledContextBudget(config.name, budget, fit));
|
|
6744
7527
|
this.agents.set(config.name, agent);
|
|
6745
7528
|
this.agentConfigs.set(config.name, config);
|
|
6746
7529
|
// First non-ephemeral agent becomes the primary for message routing
|
|
@@ -6979,11 +7762,17 @@ export class AgentFramework {
|
|
|
6979
7762
|
// queued, they flush at the target's own next boundary — with
|
|
6980
7763
|
// injection — or in driveStream's finally when its turn ends.
|
|
6981
7764
|
const midTurnInjections = [];
|
|
7765
|
+
// The injected messages' ids, for a boundary that leaves them to the
|
|
7766
|
+
// turn's next start as arrivals.
|
|
7767
|
+
const injectedIds = new Map();
|
|
6982
7768
|
{
|
|
6983
7769
|
const deferred = this.drainDeferredFor(agent.name);
|
|
6984
7770
|
if (deferred.length > 0) {
|
|
6985
7771
|
for (const msg of deferred) {
|
|
6986
|
-
agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id));
|
|
7772
|
+
const id = agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id));
|
|
7773
|
+
// Every other resident reads what this boundary stores; for its
|
|
7774
|
+
// own agent, the boundary weighs what it injects below.
|
|
7775
|
+
this.noteForOtherResidents(agent, msg.metadata, id);
|
|
6987
7776
|
// Injection guards: tool blocks would corrupt the tool-cycle
|
|
6988
7777
|
// structure the membrane enforces, and a message named as the
|
|
6989
7778
|
// agent itself would render as an ASSISTANT turn on the wire
|
|
@@ -6991,11 +7780,13 @@ export class AgentFramework {
|
|
|
6991
7780
|
// messages stay window-only — visible next turn.
|
|
6992
7781
|
const hasToolBlocks = msg.content.some((b) => b.type === 'tool_use' || b.type === 'tool_result');
|
|
6993
7782
|
if (!hasToolBlocks && msg.participant !== agent.name) {
|
|
6994
|
-
|
|
7783
|
+
const injection = {
|
|
6995
7784
|
participant: msg.participant,
|
|
6996
7785
|
content: msg.content,
|
|
6997
7786
|
...(msg.metadata ? { metadata: msg.metadata } : {}),
|
|
6998
|
-
}
|
|
7787
|
+
};
|
|
7788
|
+
midTurnInjections.push(injection);
|
|
7789
|
+
injectedIds.set(injection, id);
|
|
6999
7790
|
}
|
|
7000
7791
|
}
|
|
7001
7792
|
}
|
|
@@ -7033,13 +7824,18 @@ export class AgentFramework {
|
|
|
7033
7824
|
// under budget instead of dispatching a doomed request.
|
|
7034
7825
|
// Projection terms: prior round's real input + prior round's
|
|
7035
7826
|
// OUTPUT (the just-generated thinking/text/tool_use joins the next
|
|
7036
|
-
// round's input — omitting it is a systematic underestimate) +
|
|
7037
|
-
//
|
|
7827
|
+
// round's input — omitting it is a systematic underestimate) + the
|
|
7828
|
+
// growth since that round, when calls after it reported no prompt
|
|
7829
|
+
// (lastStreamUnreportedTokens) + blocks about to be appended +
|
|
7830
|
+
// reserve for the next response. It runs once a call on this
|
|
7831
|
+
// stream has reported a prompt: before then there is no figure to
|
|
7832
|
+
// project from.
|
|
7038
7833
|
const projectedRealTokens = currentState.stream
|
|
7039
7834
|
&& agent.physicalWindowTokens !== undefined
|
|
7040
7835
|
&& agent.lastStreamRealInputTokens > 0
|
|
7041
7836
|
? agent.lastStreamRealInputTokens
|
|
7042
7837
|
+ agent.lastStreamOutputTokens
|
|
7838
|
+
+ agent.lastStreamUnreportedTokens
|
|
7043
7839
|
+ estimateAppendedRoundTokens(currentState.toolResults, spilled, midTurnInjections)
|
|
7044
7840
|
+ agent.maxTokens
|
|
7045
7841
|
: 0;
|
|
@@ -7068,13 +7864,18 @@ export class AgentFramework {
|
|
|
7068
7864
|
// classifier-safe D-class from the 2026-07-24 ablation). It also
|
|
7069
7865
|
// updates lastAnnouncedLocus so the next turn's announce-on-change
|
|
7070
7866
|
// diffs against what the agent was actually last told.
|
|
7071
|
-
|
|
7072
|
-
|
|
7867
|
+
// The turn's own mode: a silent turn routes no prose, whatever the
|
|
7868
|
+
// agent's mode, so its boundary neither re-pins nor notes. The
|
|
7869
|
+
// trigger is this stream's: its start holds the agent's turn token
|
|
7870
|
+
// while the agent waits on this round's tools.
|
|
7871
|
+
const routesLocus = (agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid')
|
|
7872
|
+
&& !this.activeTurnTriggers.get(agent.name)?.suppressProse;
|
|
7873
|
+
if (routesLocus && !shouldEndTurn && !overBudget && currentState.stream) {
|
|
7073
7874
|
// Two signals qualify an injection to move the pin (n=6 + n=7):
|
|
7074
7875
|
// - chat:addressed — someone explicitly spoke TO the agent
|
|
7075
7876
|
// (mention / reply / DM);
|
|
7076
7877
|
// - a conversational follow-up in a channel the agent itself
|
|
7077
|
-
// explicitly sent into THIS turn (
|
|
7878
|
+
// explicitly sent into THIS turn (TurnRouting.engaged) —
|
|
7078
7879
|
// conversation the agent just engaged continues without
|
|
7079
7880
|
// re-mentioning it (2026-07-31: q's #portables reply, no @,
|
|
7080
7881
|
// answered in prose that followed the stale pin into
|
|
@@ -7085,46 +7886,28 @@ export class AgentFramework {
|
|
|
7085
7886
|
// Reactions and system markers are already excluded by
|
|
7086
7887
|
// isConversationalInjection, and the engaged-this-turn scope
|
|
7087
7888
|
// keeps unrelated channels from moving the pin.
|
|
7088
|
-
|
|
7089
|
-
const
|
|
7090
|
-
|
|
7091
|
-
|
|
7092
|
-
|
|
7093
|
-
|
|
7094
|
-
|
|
7095
|
-
|
|
7096
|
-
|
|
7097
|
-
|
|
7098
|
-
|
|
7099
|
-
|
|
7100
|
-
const newLocus = lastQualifying?.metadata
|
|
7101
|
-
?.channelId;
|
|
7102
|
-
if (newLocus && this.turnLocusPins.get(agent.name) !== newLocus) {
|
|
7103
|
-
const prevPin = this.turnLocusPins.get(agent.name) ?? null;
|
|
7104
|
-
this.turnLocusPins.set(agent.name, newLocus);
|
|
7105
|
-
this.lastAnnouncedLocus.set(agent.name, newLocus);
|
|
7106
|
-
const label = this.channelRegistry?.getDescriptor(newLocus)?.label;
|
|
7107
|
-
const shown = label && label !== newLocus
|
|
7108
|
-
? `${label.startsWith('#') ? label : `#${label}`} (${newLocus})`
|
|
7109
|
-
: newLocus;
|
|
7110
|
-
const noticeContent = [{
|
|
7111
|
-
type: 'text',
|
|
7112
|
-
text: `[routing] The conversation moved to ${shown} — your plain ` +
|
|
7113
|
-
'speech now lands there for the rest of this turn. Other ' +
|
|
7114
|
-
'channels need an explicit send tool.',
|
|
7115
|
-
}];
|
|
7116
|
-
const noticeMeta = { system: true, kind: 'routing-notice' };
|
|
7117
|
-
try {
|
|
7118
|
-
const id = agent.getContextManager().addMessage('user', noticeContent, noticeMeta);
|
|
7119
|
-
this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
|
|
7120
|
-
midTurnInjections.push({ participant: 'user', content: noticeContent, metadata: noticeMeta });
|
|
7121
|
-
console.error(`[routing] ${agent.name}: mid-turn addressed re-pin ${prevPin ?? '(none)'} -> ${newLocus}`);
|
|
7122
|
-
}
|
|
7123
|
-
catch (err) {
|
|
7124
|
-
console.error('mid-turn locus re-pin: failed to record routing notice:', err);
|
|
7125
|
-
}
|
|
7889
|
+
// The last injection that qualifies wins (repinChannel).
|
|
7890
|
+
const routing = this.routingOf(agent.name);
|
|
7891
|
+
let newLocus;
|
|
7892
|
+
for (const inj of midTurnInjections)
|
|
7893
|
+
newLocus = this.repinChannel(inj.metadata, routing) ?? newLocus;
|
|
7894
|
+
if (newLocus) {
|
|
7895
|
+
// What this boundary just stored is newer than any arrival the
|
|
7896
|
+
// turn holds, so the arrivals can't move the locus back.
|
|
7897
|
+
routing.arrivals = [];
|
|
7898
|
+
const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed');
|
|
7899
|
+
if (notice)
|
|
7900
|
+
midTurnInjections.push(notice);
|
|
7126
7901
|
}
|
|
7127
7902
|
}
|
|
7903
|
+
else if (routesLocus && !shouldEndTurn) {
|
|
7904
|
+
// A boundary that can't re-pin now (over budget, or no live
|
|
7905
|
+
// stream to tell) leaves its injections to the turn's next start,
|
|
7906
|
+
// as arrivals: a budget restart, or a re-inference on these tool
|
|
7907
|
+
// results.
|
|
7908
|
+
for (const inj of midTurnInjections)
|
|
7909
|
+
this.noteArrival(agent.name, inj.metadata, injectedIds.get(inj));
|
|
7910
|
+
}
|
|
7128
7911
|
if (shouldEndTurn) {
|
|
7129
7912
|
// endTurn: messages already stored above, cancel stream, reset to idle.
|
|
7130
7913
|
// The batch is never submitted, so nothing can refuse it: admit
|
|
@@ -7140,7 +7923,7 @@ export class AgentFramework {
|
|
|
7140
7923
|
// agent must be wakeable the moment it goes idle. (Idempotent;
|
|
7141
7924
|
// driveStream's finally is the backstop.)
|
|
7142
7925
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
7143
|
-
this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' }, this.turnRouting.get(agent.name)
|
|
7926
|
+
this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' }, this.turnRouting.get(agent.name));
|
|
7144
7927
|
this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
|
|
7145
7928
|
}
|
|
7146
7929
|
else if (overBudget || overPhysical) {
|
|
@@ -7186,6 +7969,12 @@ export class AgentFramework {
|
|
|
7186
7969
|
});
|
|
7187
7970
|
}
|
|
7188
7971
|
else if (currentState.stream) {
|
|
7972
|
+
// What this round appends is in the next call's prompt. If that
|
|
7973
|
+
// call reports no prompt, the next boundary projects it from the
|
|
7974
|
+
// growth since the last call that did.
|
|
7975
|
+
if (agent.physicalWindowTokens !== undefined) {
|
|
7976
|
+
agent.lastStreamUnreportedTokens += estimateAppendedRoundTokens(currentState.toolResults, spilled, midTurnInjections);
|
|
7977
|
+
}
|
|
7189
7978
|
// Streaming path: convert results and resume the stream.
|
|
7190
7979
|
// Mid-turn messages collected above ride along as injected user
|
|
7191
7980
|
// messages (membrane ≥0.5.72) — appended after the tool_result
|
|
@@ -7325,7 +8114,7 @@ export class AgentFramework {
|
|
|
7325
8114
|
// Same backstop as the push lane: no row, no wake, no fork spawned for a
|
|
7326
8115
|
// message with nothing visible in it.
|
|
7327
8116
|
if (isVisiblyEmptyContent(event.content)) {
|
|
7328
|
-
console.error(`[channel-incoming-dropped] server=${event.serverId} channel=${event.channelId} messageId=${event.messageId} reason=empty-content`);
|
|
8117
|
+
console.error(`[channel-incoming-dropped] server=${event.serverId} channel=${logValue(event.channelId)} messageId=${logValue(event.messageId)} reason=empty-content`);
|
|
7329
8118
|
this.emitTrace({
|
|
7330
8119
|
type: 'mcpl:empty-content-dropped', lane: 'channel', serverId: event.serverId,
|
|
7331
8120
|
channelId: event.channelId, messageId: event.messageId, ...(event.eventId ? { eventId: event.eventId } : {}),
|
|
@@ -7490,7 +8279,7 @@ export class AgentFramework {
|
|
|
7490
8279
|
}
|
|
7491
8280
|
catch (error) {
|
|
7492
8281
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
7493
|
-
console.error(`Failed to spawn conversation agent for ${event.channelId}:`, err);
|
|
8282
|
+
console.error(`Failed to spawn conversation agent for ${logValue(event.channelId)}:`, err);
|
|
7494
8283
|
this.emitTrace({
|
|
7495
8284
|
type: 'mcpl:conversation-spawn-failed',
|
|
7496
8285
|
channelId: event.channelId,
|
|
@@ -7540,9 +8329,10 @@ export class AgentFramework {
|
|
|
7540
8329
|
/**
|
|
7541
8330
|
* Spawn a persistent per-channel conversation agent ("fork") from the
|
|
7542
8331
|
* template agent: own ContextManager under `conversations/{name}`, seeded
|
|
7543
|
-
* with
|
|
7544
|
-
* wholesale-copy pattern
|
|
7545
|
-
* but never
|
|
8332
|
+
* with the template's compiled context without its thinking or its unpaired
|
|
8333
|
+
* tool blocks (the SubagentModule wholesale-copy pattern; see the seeding
|
|
8334
|
+
* comment below), registered in the event loop like any agent — but never
|
|
8335
|
+
* primary.
|
|
7546
8336
|
*/
|
|
7547
8337
|
async createConversationAgent(name, channelId) {
|
|
7548
8338
|
const router = this.conversationRouter;
|
|
@@ -7574,13 +8364,53 @@ export class AgentFramework {
|
|
|
7574
8364
|
// persisted precisely so names aren't reused, but if this namespace has
|
|
7575
8365
|
// history anyway (counter state lost, crash between spawn and persist),
|
|
7576
8366
|
// seeding again would stack another template copy on top of it.
|
|
7577
|
-
|
|
8367
|
+
//
|
|
8368
|
+
// Both compiles are dry runs: neither is sent, and the template's runs
|
|
8369
|
+
// while the template may be mid-stream, where a committing compile
|
|
8370
|
+
// would commit fold resolutions and queue the compression its fold plan
|
|
8371
|
+
// calls for (and, once context-manager's thinking binding ships, fence
|
|
8372
|
+
// its stream's replies). In context-manager 0.14 a dry run leaves
|
|
8373
|
+
// nothing else behind but the release of an expired compression hold
|
|
8374
|
+
// (see Agent.compileWithInjections), so the template's request in
|
|
8375
|
+
// flight keeps its calibration estimate.
|
|
8376
|
+
// The template's compile runs at context-manager's default budget (none
|
|
8377
|
+
// is passed), and a dry run still refuses a budget it can't fit: it
|
|
8378
|
+
// skips only the post-pick check, and an adaptive fold plan that
|
|
8379
|
+
// doesn't fit then overruns at the middle's emission. So a template
|
|
8380
|
+
// whose fold plan doesn't fit that budget fails the spawn with
|
|
8381
|
+
// context-manager's refusal, which names the tokens and the limit, as
|
|
8382
|
+
// a committing compile failed it. Only a windowed-passthrough template
|
|
8383
|
+
// whose newest message alone exceeds it seeds that message, larger than
|
|
8384
|
+
// the budget. The fork's own compiles meet its window by its strategy's
|
|
8385
|
+
// rule: passthrough, the fork's default, keeps the newest messages that
|
|
8386
|
+
// fit; an adaptive strategy folds to fit or throws over_budget for the
|
|
8387
|
+
// drain breaker.
|
|
8388
|
+
// The seed leaves out the template's thinking (withoutThinking): a
|
|
8389
|
+
// signed block verifies only while everything before it is what it was
|
|
8390
|
+
// minted under, and a fork's system, tools and scoped injections don't
|
|
8391
|
+
// promise that, while its own namespace holds no stamps that could
|
|
8392
|
+
// vouch for the copies. On models that don't bind thinking to its
|
|
8393
|
+
// prefix a copy would have been kept, so there the fork loses the
|
|
8394
|
+
// template's reasoning.
|
|
8395
|
+
// Then context-manager's stripUnpairedToolBlocks drops each call whose
|
|
8396
|
+
// result isn't in the seed and each result whose call isn't, keeping
|
|
8397
|
+
// the rest of their messages (a message left with nothing becomes its
|
|
8398
|
+
// "[tool call omitted]" placeholder). A fork spawned while the template
|
|
8399
|
+
// is between a round's call and its results (the coalescer can spawn
|
|
8400
|
+
// one outside the event loop) would otherwise give the fork's first
|
|
8401
|
+
// request an unanswered call: the passthrough strategies have no
|
|
8402
|
+
// pairing pass, and an adaptive template's compile has been through its
|
|
8403
|
+
// own.
|
|
8404
|
+
const { messages: existing } = await contextManager.compile(undefined, undefined, { dryRun: true });
|
|
7578
8405
|
if (existing.length === 0) {
|
|
7579
8406
|
// The copy is the fork's own, and the template's annotation edits only
|
|
7580
8407
|
// the template's message: seed only once each of the template's
|
|
7581
8408
|
// annotations has finished.
|
|
7582
|
-
const { messages: compiled } = await template.toolResultGuard.afterAnnotations(() => template.getContextManager().compile());
|
|
7583
|
-
|
|
8409
|
+
const { messages: compiled } = await template.toolResultGuard.afterAnnotations(() => template.getContextManager().compile(undefined, undefined, { dryRun: true }));
|
|
8410
|
+
const withoutReasoning = withoutThinking(compiled);
|
|
8411
|
+
const seed = stripUnpairedToolBlocks(withoutReasoning);
|
|
8412
|
+
logSeedOmissions(name, template.name, compiled, withoutReasoning, seed);
|
|
8413
|
+
for (const msg of seed) {
|
|
7584
8414
|
const participant = msg.participant === template.name ? name : msg.participant;
|
|
7585
8415
|
contextManager.addMessage(participant, msg.content);
|
|
7586
8416
|
}
|
|
@@ -7833,7 +8663,19 @@ export class AgentFramework {
|
|
|
7833
8663
|
throw new Error('server disconnected');
|
|
7834
8664
|
return server.sendPushRender(params);
|
|
7835
8665
|
},
|
|
7836
|
-
audit: (record) =>
|
|
8666
|
+
audit: (record) => {
|
|
8667
|
+
// A render result the host refused falls back to the occurrence's own
|
|
8668
|
+
// content; say so on the host, with the server, the subject and the
|
|
8669
|
+
// failing field, not only in the trace. The error is a field like the
|
|
8670
|
+
// rest (logValue, spaces escaped), not a host-message tail: a server
|
|
8671
|
+
// that answers push/render with an error chooses its message.
|
|
8672
|
+
if (record.kind === 'render-failed') {
|
|
8673
|
+
console.error(`[push-render-failed] server=${logValue(record.serverId ?? '')} subject=${logValue(record.subject ?? '')} ` +
|
|
8674
|
+
`eventId=${logValue(record.eventId ?? '')}${record.field !== undefined ? ` field=${logValue(record.field)}` : ''}: ` +
|
|
8675
|
+
`${logValue(record.error ?? '')} (the occurrence's own content is delivered instead)`);
|
|
8676
|
+
}
|
|
8677
|
+
this.emitTrace({ type: 'mcpl:coalescing', ...record });
|
|
8678
|
+
},
|
|
7837
8679
|
save: (snapshot) => this.scheduleCoalescingSnapshot(snapshot),
|
|
7838
8680
|
recordReceipt: (record) => {
|
|
7839
8681
|
// Written now, not at the throttled flush: an acknowledged acceptance
|
|
@@ -7951,7 +8793,7 @@ export class AgentFramework {
|
|
|
7951
8793
|
}
|
|
7952
8794
|
if (typeof message.timestamp !== 'string')
|
|
7953
8795
|
throw new CoalesceError('timestamp', 'timestamp is required');
|
|
7954
|
-
validateCoalescedContent(message.content,
|
|
8796
|
+
validateCoalescedContent(message.content, { allowEmpty: message.coalesce.retract === true });
|
|
7955
8797
|
const c = message.coalesce;
|
|
7956
8798
|
const result = await this.pushCoalescer.accept({
|
|
7957
8799
|
serverId,
|
|
@@ -7980,7 +8822,7 @@ export class AgentFramework {
|
|
|
7980
8822
|
throw new CoalesceError('eventId', 'eventId is required');
|
|
7981
8823
|
if (typeof params.timestamp !== 'string')
|
|
7982
8824
|
throw new CoalesceError('timestamp', 'timestamp is required');
|
|
7983
|
-
validateCoalescedContent(params.payload?.content,
|
|
8825
|
+
validateCoalescedContent(params.payload?.content, {
|
|
7984
8826
|
allowEmpty: params.coalesce.retract === true || isSilentHeartbeatMarker({
|
|
7985
8827
|
serverId, featureSet: params.featureSet, origin: params.origin, content: params.payload?.content,
|
|
7986
8828
|
}),
|
|
@@ -8082,7 +8924,7 @@ export class AgentFramework {
|
|
|
8082
8924
|
return [decision.agentName];
|
|
8083
8925
|
}
|
|
8084
8926
|
catch (err) {
|
|
8085
|
-
console.error(`[coalescing] could not spawn a conversation agent for ${occ.scope.id}:`, err);
|
|
8927
|
+
console.error(`[coalescing] could not spawn a conversation agent for ${logValue(occ.scope.id)}:`, err);
|
|
8086
8928
|
return [];
|
|
8087
8929
|
}
|
|
8088
8930
|
}
|
|
@@ -8280,7 +9122,7 @@ export class AgentFramework {
|
|
|
8280
9122
|
if (!agent)
|
|
8281
9123
|
return false;
|
|
8282
9124
|
try {
|
|
8283
|
-
agent.getContextManager()
|
|
9125
|
+
removeStoredMessage(agent.getContextManager(), p.messageId);
|
|
8284
9126
|
this.emitTrace({ type: 'message:removed', messageId: p.messageId, source: 'mcpl:coalescing' });
|
|
8285
9127
|
return true;
|
|
8286
9128
|
}
|
|
@@ -8299,7 +9141,7 @@ export class AgentFramework {
|
|
|
8299
9141
|
// model would wake to `[Continue]` or an older message re-presented as
|
|
8300
9142
|
// the newest, a wake with no visible cause.
|
|
8301
9143
|
if (isVisiblyEmptyContent(event.content) && !isSilentHeartbeatMarker(event)) {
|
|
8302
|
-
console.error(`[push-event-dropped] server=${event.serverId} eventId=${event.eventId} reason=empty-content`);
|
|
9144
|
+
console.error(`[push-event-dropped] server=${event.serverId} eventId=${logValue(event.eventId ?? '')} reason=empty-content`);
|
|
8303
9145
|
this.emitTrace({ type: 'mcpl:empty-content-dropped', lane: 'push', serverId: event.serverId, eventId: event.eventId });
|
|
8304
9146
|
return undefined;
|
|
8305
9147
|
}
|
|
@@ -8355,7 +9197,7 @@ export class AgentFramework {
|
|
|
8355
9197
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
|
|
8356
9198
|
}
|
|
8357
9199
|
else {
|
|
8358
|
-
console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${event.eventId}`);
|
|
9200
|
+
console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${logValue(event.eventId ?? '')}`);
|
|
8359
9201
|
}
|
|
8360
9202
|
if (event.triggerInference) {
|
|
8361
9203
|
// Default broadcast excludes conversation forks (channel-driven).
|
|
@@ -8742,13 +9584,13 @@ export class AgentFramework {
|
|
|
8742
9584
|
recordProseSuppression(routing, count) {
|
|
8743
9585
|
if (count <= 0)
|
|
8744
9586
|
return;
|
|
8745
|
-
routing.
|
|
9587
|
+
routing.suppressed += count;
|
|
8746
9588
|
}
|
|
8747
9589
|
/** Record a successful plain-prose delivery for this turn's receipt. */
|
|
8748
9590
|
recordProseDelivery(routing, outcome) {
|
|
8749
9591
|
if (!outcome?.delivered)
|
|
8750
9592
|
return;
|
|
8751
|
-
routing.
|
|
9593
|
+
routing.deliveries.push(outcome.channelId);
|
|
8752
9594
|
}
|
|
8753
9595
|
/**
|
|
8754
9596
|
* Append the turn's `[delivered]` receipt: one compact system message
|
|
@@ -8764,12 +9606,12 @@ export class AgentFramework {
|
|
|
8764
9606
|
* ([discord-send-failed]); this is the success half.
|
|
8765
9607
|
*/
|
|
8766
9608
|
appendProseDeliveryReceipt(agent, routing) {
|
|
8767
|
-
const list = routing.
|
|
8768
|
-
const suppressed = routing.
|
|
9609
|
+
const list = routing.deliveries;
|
|
9610
|
+
const suppressed = routing.suppressed;
|
|
8769
9611
|
if (list.length === 0 && suppressed === 0)
|
|
8770
9612
|
return;
|
|
8771
|
-
routing.
|
|
8772
|
-
routing.
|
|
9613
|
+
routing.deliveries = [];
|
|
9614
|
+
routing.suppressed = 0;
|
|
8773
9615
|
const seen = new Set();
|
|
8774
9616
|
const shown = [];
|
|
8775
9617
|
for (const id of list ?? []) {
|
|
@@ -8815,7 +9657,6 @@ export class AgentFramework {
|
|
|
8815
9657
|
this.lastAnnouncedLocus.set(agentName, locus);
|
|
8816
9658
|
return;
|
|
8817
9659
|
}
|
|
8818
|
-
this.lastAnnouncedLocus.set(agentName, locus);
|
|
8819
9660
|
const agent = this.agents.get(agentName);
|
|
8820
9661
|
if (!agent)
|
|
8821
9662
|
return;
|
|
@@ -8836,6 +9677,9 @@ export class AgentFramework {
|
|
|
8836
9677
|
}
|
|
8837
9678
|
try {
|
|
8838
9679
|
const id = agent.getContextManager().addMessage('user', [{ type: 'text', text }], { system: true, kind: 'routing-notice' });
|
|
9680
|
+
// The baseline is what the agent was told: it moves once the notice is
|
|
9681
|
+
// stored, so a notice that fails is announced again at the next turn.
|
|
9682
|
+
this.lastAnnouncedLocus.set(agentName, locus);
|
|
8839
9683
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
|
|
8840
9684
|
console.error(`[routing] ${agentName}: locus ${prev ?? '(none)'} -> ${locus ?? '(none)'} (announced in window)`);
|
|
8841
9685
|
}
|
|
@@ -8843,6 +9687,125 @@ export class AgentFramework {
|
|
|
8843
9687
|
console.error('announceLocusIfChanged: failed to record routing notice:', err);
|
|
8844
9688
|
}
|
|
8845
9689
|
}
|
|
9690
|
+
/**
|
|
9691
|
+
* The channel a message moves its turn's locus to, by the addressed
|
|
9692
|
+
* re-pin rule (2026-07-31, n=6 and n=7): a conversational message (not a
|
|
9693
|
+
* reaction or a system marker) that addressed the agent, or that follows
|
|
9694
|
+
* up in a channel the turn sent into (TurnRouting.engaged). Undefined for
|
|
9695
|
+
* any other message. A tool boundary weighs the messages it injects by it,
|
|
9696
|
+
* and noteArrival the ones no boundary of the turn weighs.
|
|
9697
|
+
*/
|
|
9698
|
+
repinChannel(metadata, routing) {
|
|
9699
|
+
if (!isConversationalInjection(metadata))
|
|
9700
|
+
return undefined;
|
|
9701
|
+
const m = metadata;
|
|
9702
|
+
if (typeof m?.channelId !== 'string')
|
|
9703
|
+
return undefined;
|
|
9704
|
+
if (isAddressedMessage(m.tags, m))
|
|
9705
|
+
return m.channelId;
|
|
9706
|
+
return routing.engaged.has(m.channelId) ? m.channelId : undefined;
|
|
9707
|
+
}
|
|
9708
|
+
/**
|
|
9709
|
+
* Weigh a message that no tool boundary of the agent's open logical turn
|
|
9710
|
+
* will weigh: one that would re-pin the locus becomes one of the turn's
|
|
9711
|
+
* arrivals (TurnRouting.arrivals). A message tune-out diverted
|
|
9712
|
+
* (metadata.tuneOut) never enters a resident's compiled view, so the agent
|
|
9713
|
+
* won't see it, and it moves nothing.
|
|
9714
|
+
*/
|
|
9715
|
+
noteArrival(agentName, metadata, messageId) {
|
|
9716
|
+
const open = this.openLogical.get(agentName);
|
|
9717
|
+
if (!open)
|
|
9718
|
+
return;
|
|
9719
|
+
if (metadata?.tuneOut)
|
|
9720
|
+
return;
|
|
9721
|
+
const channelId = this.repinChannel(metadata, open);
|
|
9722
|
+
if (channelId)
|
|
9723
|
+
open.arrivals.push({ messageId, channelId });
|
|
9724
|
+
}
|
|
9725
|
+
/**
|
|
9726
|
+
* Whether `agent`'s store is the residents' one message slot, which every
|
|
9727
|
+
* ordinary resident reads (sharedSlotAgents). The subconscious reads that
|
|
9728
|
+
* slot through a merged view, but its own store is isolated, as a fork's
|
|
9729
|
+
* or an ephemeral agent's is.
|
|
9730
|
+
*/
|
|
9731
|
+
isOrdinaryResident(agent) {
|
|
9732
|
+
return this.sharedSlotAgents.has(agent) && agent.name !== this.subconsciousAgentName;
|
|
9733
|
+
}
|
|
9734
|
+
/**
|
|
9735
|
+
* Weigh a message just stored in an ordinary resident's store for every
|
|
9736
|
+
* other ordinary resident's open turn, running or not: residents read one
|
|
9737
|
+
* message slot, and a tool boundary weighs only the messages drained for
|
|
9738
|
+
* its own agent, so no boundary of another resident's turn weighs it.
|
|
9739
|
+
*/
|
|
9740
|
+
noteForOtherResidents(agent, metadata, messageId) {
|
|
9741
|
+
if (!this.isOrdinaryResident(agent))
|
|
9742
|
+
return;
|
|
9743
|
+
for (const reader of this.sharedSlotAgents) {
|
|
9744
|
+
if (reader !== agent && this.agents.get(reader.name) === reader && this.isOrdinaryResident(reader)) {
|
|
9745
|
+
this.noteArrival(reader.name, metadata, messageId);
|
|
9746
|
+
}
|
|
9747
|
+
}
|
|
9748
|
+
}
|
|
9749
|
+
/**
|
|
9750
|
+
* The channel of the turn's newest arrival whose message is still in the
|
|
9751
|
+
* agent's store (TurnRouting.arrivals). A message removed since it was
|
|
9752
|
+
* stored, such as an unread coalesced message its server retracted, moves
|
|
9753
|
+
* nothing, and an earlier one that remains still does. Compression keeps a
|
|
9754
|
+
* message in the store; the last compile can't be the test, since an
|
|
9755
|
+
* arrival between the turn's streams isn't in it yet.
|
|
9756
|
+
*/
|
|
9757
|
+
remainingArrival(agent, routing) {
|
|
9758
|
+
const cm = agent.getContextManager();
|
|
9759
|
+
for (let i = routing.arrivals.length - 1; i >= 0; i--) {
|
|
9760
|
+
const { messageId, channelId } = routing.arrivals[i];
|
|
9761
|
+
// The store answers null for a message that isn't there. A store that
|
|
9762
|
+
// can't be read throws here, as anywhere else in the start, rather
|
|
9763
|
+
// than have its candidates read as withdrawn.
|
|
9764
|
+
if (cm.getMessage(messageId))
|
|
9765
|
+
return channelId;
|
|
9766
|
+
}
|
|
9767
|
+
return undefined;
|
|
9768
|
+
}
|
|
9769
|
+
/**
|
|
9770
|
+
* Move a turn's locus to the channel its conversation moved to: an
|
|
9771
|
+
* addressed message, or a follow-up in a channel the turn engaged (the
|
|
9772
|
+
* addressed re-pin, repinChannel). Ambient chatter never moves it. Leaves
|
|
9773
|
+
* a durable `[routing]` notice in the window and updates
|
|
9774
|
+
* lastAnnouncedLocus once it's stored, so the next turn's announce-on-change
|
|
9775
|
+
* diffs against what the agent was last told. Returns the notice as stored, for a live
|
|
9776
|
+
* stream to inject with its boundary's other messages. Returns undefined
|
|
9777
|
+
* when the locus is already there, and when the notice couldn't be
|
|
9778
|
+
* stored. The locus has moved then too, but the announce baseline hasn't,
|
|
9779
|
+
* so the agent's next turn announces where its speech lands.
|
|
9780
|
+
*/
|
|
9781
|
+
repinTurnLocus(agent, routing, channelId, cause) {
|
|
9782
|
+
if (routing.locus === channelId)
|
|
9783
|
+
return undefined;
|
|
9784
|
+
const prevPin = routing.locus ?? null;
|
|
9785
|
+
routing.locus = channelId;
|
|
9786
|
+
const label = this.channelRegistry?.getDescriptor(channelId)?.label;
|
|
9787
|
+
const shown = label && label !== channelId
|
|
9788
|
+
? `${label.startsWith('#') ? label : `#${label}`} (${channelId})`
|
|
9789
|
+
: channelId;
|
|
9790
|
+
const content = [{
|
|
9791
|
+
type: 'text',
|
|
9792
|
+
text: `[routing] The conversation moved to ${shown} — your plain ` +
|
|
9793
|
+
'speech now lands there for the rest of this turn. Other ' +
|
|
9794
|
+
'channels need an explicit send tool.',
|
|
9795
|
+
}];
|
|
9796
|
+
const metadata = { system: true, kind: 'routing-notice' };
|
|
9797
|
+
try {
|
|
9798
|
+
const id = agent.getContextManager().addMessage('user', content, metadata);
|
|
9799
|
+
this.lastAnnouncedLocus.set(agent.name, channelId);
|
|
9800
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
|
|
9801
|
+
console.error(`[routing] ${agent.name}: ${cause} re-pin ${prevPin ?? '(none)'} -> ${channelId}`);
|
|
9802
|
+
return { participant: 'user', content, metadata };
|
|
9803
|
+
}
|
|
9804
|
+
catch (err) {
|
|
9805
|
+
console.error('turn locus re-pin: failed to record routing notice:', err);
|
|
9806
|
+
return undefined;
|
|
9807
|
+
}
|
|
9808
|
+
}
|
|
8846
9809
|
/**
|
|
8847
9810
|
* Durable window notice for a channel the agent did not ask to open —
|
|
8848
9811
|
* subscription-policy admission, or an open forced by the agent's own
|
|
@@ -8889,11 +9852,11 @@ export class AgentFramework {
|
|
|
8889
9852
|
*/
|
|
8890
9853
|
requestProseContinuation(agent, routing) {
|
|
8891
9854
|
// The flag may be shared with an earlier stream of the same logical
|
|
8892
|
-
// turn (
|
|
9855
|
+
// turn (TurnRouting); its deliveries are on the agent's chain ahead of
|
|
8893
9856
|
// this stream's, so by the time this check runs, they have run.
|
|
8894
|
-
if (!routing.
|
|
9857
|
+
if (!routing.continuation)
|
|
8895
9858
|
return;
|
|
8896
|
-
routing.
|
|
9859
|
+
routing.continuation = false;
|
|
8897
9860
|
// An explicit turn's check can run after its stream has returned. A
|
|
8898
9861
|
// stopped framework, or an agent no longer registered under its name (a
|
|
8899
9862
|
// disposed fork or ephemeral run), takes no new turn from it.
|
|
@@ -8997,7 +9960,7 @@ export class AgentFramework {
|
|
|
8997
9960
|
const attempted = normalized.startsWith('>>>');
|
|
8998
9961
|
const parsed = parseHybridProsePrefix(normalized);
|
|
8999
9962
|
if (parsed.continueTurn)
|
|
9000
|
-
routing.
|
|
9963
|
+
routing.continuation = true;
|
|
9001
9964
|
if (parsed.kind === 'private') {
|
|
9002
9965
|
delete routing.pin;
|
|
9003
9966
|
routing.hybridHold = true;
|
|
@@ -9076,7 +10039,7 @@ export class AgentFramework {
|
|
|
9076
10039
|
// A private round (one that called skip_reply) asks for no publication
|
|
9077
10040
|
// or continuation, even when delivery runs after that turn has ended.
|
|
9078
10041
|
if (parsed.continueTurn && !round.keepPrivate)
|
|
9079
|
-
routing.
|
|
10042
|
+
routing.continuation = true;
|
|
9080
10043
|
if (parsed.kind === 'private') {
|
|
9081
10044
|
// The text already lives in the assistant message (window/chronicle);
|
|
9082
10045
|
// "delivery" is deliberately a no-op. Consumes the retained text if the
|
|
@@ -9349,6 +10312,22 @@ export class AgentFramework {
|
|
|
9349
10312
|
try {
|
|
9350
10313
|
tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate);
|
|
9351
10314
|
}
|
|
10315
|
+
catch (error) {
|
|
10316
|
+
// A throw outside the stream's own failure handling (the checkpoint,
|
|
10317
|
+
// locus resolution, the typing indicator) drops this start's trigger,
|
|
10318
|
+
// and nothing retries or holds it. The agent's open logical turn is the
|
|
10319
|
+
// one this start was opening, continuing or resuming, so it ends here,
|
|
10320
|
+
// as a terminal failure in beginAgentTurn's own catch ends its turn:
|
|
10321
|
+
// the next start opens a turn of its own rather than continuing this
|
|
10322
|
+
// one's routing, and an ephemeral run fails with the error rather than
|
|
10323
|
+
// waiting out its watchdog. A silent start has no part in an open turn.
|
|
10324
|
+
if (!trigger?.suppressProse) {
|
|
10325
|
+
this.settleAgent(agent.name, {
|
|
10326
|
+
stopReason: 'exhausted', speech: '', error: error instanceof Error ? error.message : String(error),
|
|
10327
|
+
}, this.openLogical.get(agent.name));
|
|
10328
|
+
}
|
|
10329
|
+
throw error;
|
|
10330
|
+
}
|
|
9352
10331
|
finally {
|
|
9353
10332
|
if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) {
|
|
9354
10333
|
this.activeTurnTokens.delete(agent.name);
|
|
@@ -9356,8 +10335,52 @@ export class AgentFramework {
|
|
|
9356
10335
|
}
|
|
9357
10336
|
if (!tokenHandedOff && ownsProviderGate)
|
|
9358
10337
|
this.releasePrimaryProviderGate(agent.name);
|
|
10338
|
+
// A start that ends before its stream exists lands what was deferred
|
|
10339
|
+
// during it, as a stream's teardown does. Left queued, those messages
|
|
10340
|
+
// would land only at the agent's next start, after any stored meanwhile.
|
|
10341
|
+
// Per message, as the turn-start flush does: one store failure must
|
|
10342
|
+
// neither drop the messages behind it nor replace the start's own error.
|
|
10343
|
+
if (!tokenHandedOff && !this.activeTurnTokens.has(agent.name)
|
|
10344
|
+
&& this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
|
|
10345
|
+
for (const msg of this.drainDeferredFor(agent.name)) {
|
|
10346
|
+
try {
|
|
10347
|
+
this.addMessage(msg.participant, msg.content, msg.metadata, {
|
|
10348
|
+
deferredWriteId: msg.id,
|
|
10349
|
+
...(msg.forAgent ? { forAgent: msg.forAgent } : {}),
|
|
10350
|
+
});
|
|
10351
|
+
}
|
|
10352
|
+
catch (err) {
|
|
10353
|
+
console.error(`[deferred-flush] failed-start flush failed to store a message for ${agent.name} ` +
|
|
10354
|
+
`(participant=${msg.participant}, id=${msg.id}):`, err);
|
|
10355
|
+
}
|
|
10356
|
+
}
|
|
10357
|
+
this.ackDeferredWrites();
|
|
10358
|
+
}
|
|
9359
10359
|
}
|
|
9360
10360
|
}
|
|
10361
|
+
/**
|
|
10362
|
+
* End a turn that beginAgentTurn drops before it sends anything. The turn
|
|
10363
|
+
* emitted inference:started, and listeners that bracket a turn, such as a
|
|
10364
|
+
* typing loop or an activity indicator, end it on a terminal event, so it
|
|
10365
|
+
* gets inference:aborted with `reason`, and lastInference records its end
|
|
10366
|
+
* as driveStream's would, when the record is still this turn's. Nothing of
|
|
10367
|
+
* the kind when another turn of this agent's name holds the turn token (one
|
|
10368
|
+
* begun after a stop and start, or by a replacement agent object): the
|
|
10369
|
+
* event and the record carry only the name, so this frame's would end that
|
|
10370
|
+
* newer turn instead.
|
|
10371
|
+
*/
|
|
10372
|
+
abortDroppedTurn(agentName, turnToken, reason, startedAt) {
|
|
10373
|
+
const owner = this.activeTurnTokens.get(agentName);
|
|
10374
|
+
if (owner !== undefined && owner !== turnToken)
|
|
10375
|
+
return;
|
|
10376
|
+
const now = Date.now();
|
|
10377
|
+
// Only this turn's own record ends: a name whose run was disposed meanwhile
|
|
10378
|
+
// (runEphemeralToCompletion deletes its record) gets none back.
|
|
10379
|
+
const last = this.lastInferenceAt.get(agentName);
|
|
10380
|
+
if (last?.startedAt === startedAt)
|
|
10381
|
+
this.lastInferenceAt.set(agentName, { ...last, endedAt: now });
|
|
10382
|
+
this.emitTrace({ type: 'inference:aborted', agentName, reason, durationMs: now - startedAt });
|
|
10383
|
+
}
|
|
9361
10384
|
/**
|
|
9362
10385
|
* Body of startAgentStream, split out so the caller's try/finally owns the
|
|
9363
10386
|
* turn token unconditionally. Returns true iff the token was handed off to
|
|
@@ -9365,7 +10388,7 @@ export class AgentFramework {
|
|
|
9365
10388
|
*/
|
|
9366
10389
|
async beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate) {
|
|
9367
10390
|
const lifecycle = this.lifecycleGeneration;
|
|
9368
|
-
const
|
|
10391
|
+
const resumesTurn = resumesLogicalTurn(trigger?.reason);
|
|
9369
10392
|
const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
|
|
9370
10393
|
// Flush messages deferred during the PREVIOUS turn — before the
|
|
9371
10394
|
// checkpoint, the locus announcement, and the compile — so a turn started
|
|
@@ -9382,7 +10405,7 @@ export class AgentFramework {
|
|
|
9382
10405
|
// Before recordTurnCheckpoint: these are the turn's inputs, not its
|
|
9383
10406
|
// products — undoing the turn must not destroy them.
|
|
9384
10407
|
if (attempt === 0 &&
|
|
9385
|
-
!
|
|
10408
|
+
!resumesTurn &&
|
|
9386
10409
|
this.deferredMessages.length > 0) {
|
|
9387
10410
|
{
|
|
9388
10411
|
const deferred = this.drainDeferredFor(agent.name);
|
|
@@ -9392,6 +10415,8 @@ export class AgentFramework {
|
|
|
9392
10415
|
// abort the turn or drop the messages behind it in the queue.
|
|
9393
10416
|
try {
|
|
9394
10417
|
const id = agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id));
|
|
10418
|
+
this.noteArrival(agent.name, msg.metadata, id);
|
|
10419
|
+
this.noteForOtherResidents(agent, msg.metadata, id);
|
|
9395
10420
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'deferred-flush:turn-start' });
|
|
9396
10421
|
}
|
|
9397
10422
|
catch (err) {
|
|
@@ -9407,7 +10432,7 @@ export class AgentFramework {
|
|
|
9407
10432
|
// not yet run) and before the checkpoint (they are the turn's inputs).
|
|
9408
10433
|
// Not on a context-budget restart: that continues the same logical turn.
|
|
9409
10434
|
let assembly;
|
|
9410
|
-
if (attempt === 0 && !
|
|
10435
|
+
if (attempt === 0 && !resumesTurn && this.pushCoalescer?.pendingBatches()) {
|
|
9411
10436
|
try {
|
|
9412
10437
|
assembly = await this.pushCoalescer.assemble(agent.name);
|
|
9413
10438
|
}
|
|
@@ -9452,44 +10477,61 @@ export class AgentFramework {
|
|
|
9452
10477
|
this.recordTurnCheckpoint(agent.name);
|
|
9453
10478
|
this.redoStacks.delete(agent.name); // new work invalidates redo
|
|
9454
10479
|
}
|
|
9455
|
-
//
|
|
9456
|
-
//
|
|
9457
|
-
//
|
|
9458
|
-
//
|
|
9459
|
-
//
|
|
9460
|
-
//
|
|
9461
|
-
//
|
|
9462
|
-
//
|
|
9463
|
-
//
|
|
9464
|
-
//
|
|
9465
|
-
//
|
|
9466
|
-
//
|
|
9467
|
-
//
|
|
9468
|
-
|
|
9469
|
-
|
|
9470
|
-
|
|
10480
|
+
// This start's routing: its logical turn's (TurnRouting), which holds
|
|
10481
|
+
// everything the turn has decided about where its speech goes.
|
|
10482
|
+
// - A resume (a budget restart, a tool-result-guard retry) carries its
|
|
10483
|
+
// turn's request on and keeps the turn's object as it is, and the
|
|
10484
|
+
// turn stays, or after a stop() becomes again, the agent's open turn.
|
|
10485
|
+
// - A silent start routes no prose. It gets an object of its own, with
|
|
10486
|
+
// no locus, so it neither takes nor disturbs an open turn's. Its
|
|
10487
|
+
// object is the agent's live routing while it runs, and the open
|
|
10488
|
+
// turn's next start makes the turn's object live again. (No silent
|
|
10489
|
+
// start runs while a prose turn is open in production: a capacity
|
|
10490
|
+
// hold holds silent wakes with the turn's, and a re-inference's wake
|
|
10491
|
+
// is queued before its stream ends, so it batches with them.)
|
|
10492
|
+
// - Any other start continues the agent's open logical turn, if one is
|
|
10493
|
+
// open (openLogical): a framework retry, the start that consumes tool
|
|
10494
|
+
// results the non-streaming fallback left (whichever wake the
|
|
10495
|
+
// scheduler starts it with), the later compile a provider-capacity
|
|
10496
|
+
// hold defers a turn to. It takes the turn's object: the sticky
|
|
10497
|
+
// destination, the destination and the hold, the ` !` and receipt
|
|
10498
|
+
// tallies, the trigger channel, the locus with any re-pin, and the
|
|
10499
|
+
// engaged channels. A delivery still queued from an earlier stream
|
|
10500
|
+
// runs ahead of this one's on the agent's chain, so what it sets
|
|
10501
|
+
// applies to this stream's prose, in the order the turn wrote them.
|
|
10502
|
+
// With no turn open, the start opens one.
|
|
10503
|
+
// A new object doesn't clear the previous turn's: prose that turn left
|
|
10504
|
+
// queued still reads and writes its own.
|
|
10505
|
+
let routing;
|
|
10506
|
+
let opensTurn = false;
|
|
10507
|
+
if (resumesTurn) {
|
|
10508
|
+
routing = this.routingOf(agent.name);
|
|
10509
|
+
// The turn a resume continues is open, unless a stop() ended every
|
|
10510
|
+
// open turn since: it opens again, so its later starts continue it.
|
|
10511
|
+
if (!trigger?.suppressProse && !this.openLogical.has(agent.name))
|
|
10512
|
+
this.openLogical.set(agent.name, routing);
|
|
10513
|
+
}
|
|
10514
|
+
else if (trigger?.suppressProse) {
|
|
10515
|
+
routing = freshTurnRouting();
|
|
10516
|
+
opensTurn = true;
|
|
10517
|
+
}
|
|
10518
|
+
else {
|
|
10519
|
+
const open = this.openLogical.get(agent.name);
|
|
10520
|
+
if (open) {
|
|
10521
|
+
routing = open;
|
|
9471
10522
|
}
|
|
9472
10523
|
else {
|
|
9473
|
-
|
|
9474
|
-
|
|
9475
|
-
|
|
9476
|
-
|
|
9477
|
-
|
|
9478
|
-
|
|
9479
|
-
|
|
9480
|
-
// reactions hijacked it before, 2026-07-21). A context-budget restart
|
|
9481
|
-
// continues the same logical turn, so it keeps the existing pin. The
|
|
9482
|
-
// eager snapshot also protects heartbeat turns from a moving
|
|
9483
|
-
// defaultPublishChannel mid-turn. If the effective locus differs from the
|
|
9484
|
-
// last one announced, drop a durable `[routing]` notice into the window
|
|
9485
|
-
// BEFORE this turn compiles, so the agent always knows where its voice
|
|
9486
|
-
// goes (announce-on-change only — no per-turn chatter, append-only for
|
|
9487
|
-
// KV stability).
|
|
9488
|
-
if (continuingTurn) {
|
|
10524
|
+
routing = freshTurnRouting();
|
|
10525
|
+
this.openLogical.set(agent.name, routing);
|
|
10526
|
+
opensTurn = true;
|
|
10527
|
+
}
|
|
10528
|
+
}
|
|
10529
|
+
this.turnRouting.set(agent.name, routing);
|
|
10530
|
+
if (resumesTurn) {
|
|
9489
10531
|
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
9490
10532
|
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
9491
10533
|
}
|
|
9492
|
-
|
|
10534
|
+
else {
|
|
9493
10535
|
if (attempt === 0)
|
|
9494
10536
|
this.maybePrimeProseMode(agent);
|
|
9495
10537
|
const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
|
|
@@ -9501,77 +10543,65 @@ export class AgentFramework {
|
|
|
9501
10543
|
// A framework retry is a new physical stream in the same logical turn.
|
|
9502
10544
|
this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
|
|
9503
10545
|
}
|
|
9504
|
-
|
|
9505
|
-
|
|
9506
|
-
|
|
9507
|
-
//
|
|
9508
|
-
//
|
|
9509
|
-
//
|
|
9510
|
-
//
|
|
9511
|
-
|
|
9512
|
-
//
|
|
9513
|
-
//
|
|
9514
|
-
//
|
|
9515
|
-
// (
|
|
9516
|
-
//
|
|
9517
|
-
//
|
|
9518
|
-
|
|
9519
|
-
|
|
9520
|
-
|
|
9521
|
-
|
|
9522
|
-
|
|
9523
|
-
|
|
9524
|
-
|
|
9525
|
-
this.turnRouting.set(agent.name, fresh);
|
|
9526
|
-
if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') {
|
|
9527
|
-
// Explicit and disabled prose routing have no inferred locus.
|
|
9528
|
-
// Explicit mode uses a turn-scoped `>>` target; disabled mode has no
|
|
9529
|
-
// prose target at all. Neither mode freezes or announces a locus.
|
|
9530
|
-
this.midTurnInputSignals.delete(agent.name);
|
|
9531
|
-
this.turnLocusPins.delete(agent.name);
|
|
9532
|
-
}
|
|
9533
|
-
else {
|
|
10546
|
+
this.midTurnInputSignals.delete(agent.name);
|
|
10547
|
+
}
|
|
10548
|
+
if (opensTurn) {
|
|
10549
|
+
// The turn's trigger channel (item-3 redux): the channel of a
|
|
10550
|
+
// channel/DM-triggered turn, and none for a heartbeat / no-trigger
|
|
10551
|
+
// turn, which falls back to the global default rather than inheriting
|
|
10552
|
+
// a previous turn's channel.
|
|
10553
|
+
routing.triggerChannel = trigger?.channelId;
|
|
10554
|
+
// FREEZE the turn's outbound locus now (turn-frozen routing): the home
|
|
10555
|
+
// channel for forks, else the trigger channel, else the global default.
|
|
10556
|
+
// Mid-turn injections must not redirect the agent's plain prose
|
|
10557
|
+
// (ambient chatter and reactions hijacked it before, 2026-07-21), and
|
|
10558
|
+
// the eager snapshot protects heartbeat turns from a moving
|
|
10559
|
+
// defaultPublishChannel. Explicit mode uses a turn-scoped `>>` target
|
|
10560
|
+
// and disabled mode has no prose target, so neither freezes or
|
|
10561
|
+
// announces a locus. If the effective locus differs from the last one
|
|
10562
|
+
// announced, a durable `[routing]` notice goes into the window BEFORE
|
|
10563
|
+
// this turn compiles, so the agent always knows where its voice goes
|
|
10564
|
+
// (announce-on-change only — no per-turn chatter, append-only for KV
|
|
10565
|
+
// stability).
|
|
10566
|
+
if (turnProseRouting !== 'explicit' && turnProseRouting !== 'disabled') {
|
|
9534
10567
|
const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null;
|
|
9535
10568
|
if (locus !== null)
|
|
9536
|
-
|
|
9537
|
-
else
|
|
9538
|
-
this.turnLocusPins.delete(agent.name);
|
|
9539
|
-
this.midTurnInputSignals.delete(agent.name);
|
|
10569
|
+
routing.locus = locus;
|
|
9540
10570
|
if (attempt === 0)
|
|
9541
10571
|
this.announceLocusIfChanged(agent.name, locus);
|
|
9542
10572
|
}
|
|
9543
10573
|
}
|
|
9544
|
-
|
|
9545
|
-
|
|
9546
|
-
|
|
9547
|
-
|
|
9548
|
-
|
|
9549
|
-
//
|
|
9550
|
-
|
|
9551
|
-
|
|
9552
|
-
|
|
9553
|
-
|
|
9554
|
-
|
|
9555
|
-
|
|
9556
|
-
}
|
|
9557
|
-
//
|
|
9558
|
-
//
|
|
9559
|
-
const
|
|
10574
|
+
else {
|
|
10575
|
+
// Every other start of the turn keeps its locus: a resume, or a start
|
|
10576
|
+
// that continues the open turn. A message no tool boundary of the turn
|
|
10577
|
+
// weighed can still move it, as it would have at a boundary
|
|
10578
|
+
// (TurnRouting.arrivals).
|
|
10579
|
+
// Only a turn that has a locus reads its arrivals' messages.
|
|
10580
|
+
const arrival = turnProseRouting === 'locus' || turnProseRouting === 'hybrid'
|
|
10581
|
+
? this.remainingArrival(agent, routing)
|
|
10582
|
+
: undefined;
|
|
10583
|
+
routing.arrivals = [];
|
|
10584
|
+
if (arrival)
|
|
10585
|
+
this.repinTurnLocus(agent, routing, arrival, 'arrival');
|
|
10586
|
+
}
|
|
10587
|
+
// A silent start, or a disabled agent's, has no locus to show, whatever a
|
|
10588
|
+
// channel_open in a silent turn left on its object.
|
|
10589
|
+
const turnLocus = turnProseRouting === 'disabled' ? undefined : routing.locus;
|
|
9560
10590
|
this.touchEphemeralRun(agent.name, true);
|
|
10591
|
+
const turnStartedAt = Date.now();
|
|
9561
10592
|
this.emitTrace({
|
|
9562
10593
|
type: 'inference:started',
|
|
9563
10594
|
agentName: agent.name,
|
|
9564
|
-
// The turn
|
|
9565
|
-
|
|
9566
|
-
channelId: this.turnLocusPins.get(agent.name),
|
|
10595
|
+
// The turn's locus, kept across a start that continues the turn.
|
|
10596
|
+
channelId: turnLocus,
|
|
9567
10597
|
...(trigger?.suppressProse ? { silent: true } : {}),
|
|
9568
10598
|
});
|
|
9569
10599
|
// A budget restart continues the same logical inference window. The
|
|
9570
10600
|
// predecessor keeps EventGate liveness until its successor terminates.
|
|
9571
|
-
if (!
|
|
10601
|
+
if (!resumesTurn) {
|
|
9572
10602
|
this.eventGate?.onInferenceStarted(agent.name);
|
|
9573
10603
|
}
|
|
9574
|
-
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt:
|
|
10604
|
+
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: turnStartedAt });
|
|
9575
10605
|
// Typing indicator, started at TURN START. It says "attending", which is
|
|
9576
10606
|
// true from the moment the turn is accepted — but it used to start inside
|
|
9577
10607
|
// driveStream, i.e. only after module gatherContext + MCPL beforeInference
|
|
@@ -9583,7 +10613,7 @@ export class AgentFramework {
|
|
|
9583
10613
|
// it on the no-driveStream failure paths (e.g. a compile refusal).
|
|
9584
10614
|
const earlyTypingChannel = turnProseRouting === 'explicit'
|
|
9585
10615
|
? trigger?.channelId ?? null
|
|
9586
|
-
:
|
|
10616
|
+
: turnLocus ?? null;
|
|
9587
10617
|
if (earlyTypingChannel)
|
|
9588
10618
|
this.channelRegistry?.startTyping(earlyTypingChannel);
|
|
9589
10619
|
try {
|
|
@@ -9635,8 +10665,13 @@ export class AgentFramework {
|
|
|
9635
10665
|
// and stop() may close provider admission. Do not create a provider
|
|
9636
10666
|
// stream after its generation lost ownership, or once admission closed.
|
|
9637
10667
|
if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed) {
|
|
10668
|
+
const why = this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed' : 'the framework was stopping';
|
|
10669
|
+
const reason = `a turn assembled while ${why} is dropped before it sends anything`;
|
|
10670
|
+
console.error(`[turn] ${agent.name}: ${reason}`);
|
|
10671
|
+
this.endDroppedTurn(agent.name, routing);
|
|
9638
10672
|
this.channelRegistry?.stopTyping();
|
|
9639
10673
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
10674
|
+
this.abortDroppedTurn(agent.name, turnToken, reason, turnStartedAt);
|
|
9640
10675
|
return false;
|
|
9641
10676
|
}
|
|
9642
10677
|
// The subconscious's standing dispositions ride a fixed system-position
|
|
@@ -9658,9 +10693,11 @@ export class AgentFramework {
|
|
|
9658
10693
|
// Membrane's yielding stream starts its request when it is first
|
|
9659
10694
|
// iterated, so cancelling it here, before driveStream, sends nothing.
|
|
9660
10695
|
if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed || lifecycle !== this.lifecycleGeneration) {
|
|
9661
|
-
|
|
10696
|
+
const why = this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed'
|
|
9662
10697
|
: this.providerAdmissionClosed ? 'the framework was stopping'
|
|
9663
|
-
: 'the framework was stopped and started again'
|
|
10698
|
+
: 'the framework was stopped and started again';
|
|
10699
|
+
const reason = `a turn compiled while ${why} is dropped before it sends anything`;
|
|
10700
|
+
console.error(`[turn] ${agent.name}: ${reason}`);
|
|
9664
10701
|
stream.cancel();
|
|
9665
10702
|
agent.cancelStream();
|
|
9666
10703
|
if (this.agents.get(agent.name) === agent) {
|
|
@@ -9670,8 +10707,10 @@ export class AgentFramework {
|
|
|
9670
10707
|
this.activeStreams.delete(agent.name);
|
|
9671
10708
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
9672
10709
|
}
|
|
10710
|
+
this.endDroppedTurn(agent.name, routing);
|
|
9673
10711
|
this.channelRegistry?.stopTyping();
|
|
9674
10712
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
10713
|
+
this.abortDroppedTurn(agent.name, turnToken, reason, turnStartedAt);
|
|
9675
10714
|
return false;
|
|
9676
10715
|
}
|
|
9677
10716
|
// A conversation agent takes no provider gate, so its own compression
|
|
@@ -9756,11 +10795,13 @@ export class AgentFramework {
|
|
|
9756
10795
|
this.activeTurnTokens.delete(agent.name);
|
|
9757
10796
|
this.activeTurnTriggers.delete(agent.name);
|
|
9758
10797
|
}
|
|
10798
|
+
// This start's own logical turn: a failure before its stream exists
|
|
10799
|
+
// settles it, and no other start's.
|
|
9759
10800
|
this.settleAgent(agent.name, {
|
|
9760
10801
|
stopReason: 'exhausted',
|
|
9761
10802
|
speech: '',
|
|
9762
|
-
|
|
9763
|
-
},
|
|
10803
|
+
...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
|
|
10804
|
+
}, routing);
|
|
9764
10805
|
this.emitTrace({
|
|
9765
10806
|
type: 'inference:exhausted',
|
|
9766
10807
|
agentName: agent.name,
|
|
@@ -9796,8 +10837,9 @@ export class AgentFramework {
|
|
|
9796
10837
|
const myStreamId = agent.streamId;
|
|
9797
10838
|
// Membrane usage events are cumulative across the native/XML tool loop.
|
|
9798
10839
|
// Keep the previous cumulative sample so consumers that operate at the
|
|
9799
|
-
// physical provider-call boundary (estimator calibration and
|
|
9800
|
-
//
|
|
10840
|
+
// physical provider-call boundary (estimator calibration and the
|
|
10841
|
+
// physical-window projection) see this call only, not an ever-growing
|
|
10842
|
+
// turn total.
|
|
9801
10843
|
let previousUsage = {
|
|
9802
10844
|
inputTokens: 0,
|
|
9803
10845
|
outputTokens: 0,
|
|
@@ -9820,14 +10862,14 @@ export class AgentFramework {
|
|
|
9820
10862
|
let hadToolCalls = false;
|
|
9821
10863
|
let generationLost = false;
|
|
9822
10864
|
// ---- Present-while-acting turn state ---------------------------------
|
|
9823
|
-
// Output locus for the WHOLE logical turn: frozen
|
|
9824
|
-
// (home → addressed trigger → global default)
|
|
9825
|
-
//
|
|
9826
|
-
//
|
|
9827
|
-
// not redirect the agent's
|
|
9828
|
-
//
|
|
9829
|
-
//
|
|
9830
|
-
const resolveTurnLocus = () =>
|
|
10865
|
+
// Output locus for the WHOLE logical turn: frozen when the turn opened
|
|
10866
|
+
// (home → addressed trigger → global default), and moved only by a
|
|
10867
|
+
// re-pin (an addressed message, or a follow-up in a channel the turn
|
|
10868
|
+
// engaged) or the agent's own channel_open. Ambient messages and
|
|
10869
|
+
// reactions arriving at a tool boundary must not redirect the agent's
|
|
10870
|
+
// task narration (2026-07-21 Cairn misroute). The locus lives in the
|
|
10871
|
+
// turn's routing state, so every stream of the turn shares it.
|
|
10872
|
+
const resolveTurnLocus = () => streamRouting.locus ?? null;
|
|
9831
10873
|
// Ordered delivery chain for live-routed prose. Links are enqueued
|
|
9832
10874
|
// WITHOUT awaiting in the stream-event loop — an awaited network post
|
|
9833
10875
|
// here would stall consumption of the next round's events by
|
|
@@ -9838,11 +10880,13 @@ export class AgentFramework {
|
|
|
9838
10880
|
//
|
|
9839
10881
|
// Every stream queues its deliveries behind the agent's latest link, so
|
|
9840
10882
|
// the agent's posts go out in the order they were written, across a
|
|
9841
|
-
// turn's streams
|
|
9842
|
-
//
|
|
9843
|
-
//
|
|
9844
|
-
//
|
|
9845
|
-
|
|
10883
|
+
// turn's streams and across turns. So an earlier stream's deliveries,
|
|
10884
|
+
// and the sticky destination or hold they set, come before this stream's
|
|
10885
|
+
// own. A stream queues only while it is the agent's current stream: the
|
|
10886
|
+
// event loop stops at the first event after a replacement. A turn that a
|
|
10887
|
+
// tool ends (skip_reply) settles before its queued prose has posted; that
|
|
10888
|
+
// prose keeps its own turn's routing state (turnRouting), and the next
|
|
10889
|
+
// turn's posts wait behind it.
|
|
9846
10890
|
let turnSpeechChain = this.proseSpeechChains.get(agent.name) ?? Promise.resolve();
|
|
9847
10891
|
this.proseSpeechChains.set(agent.name, turnSpeechChain);
|
|
9848
10892
|
const chainSpeech = (link, failure) => {
|
|
@@ -9929,14 +10973,14 @@ export class AgentFramework {
|
|
|
9929
10973
|
? new ProseStreamRouter({
|
|
9930
10974
|
mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus',
|
|
9931
10975
|
// Explicit text has no destination until a `>>` prefix names one,
|
|
9932
|
-
// so a fresh turn's stream starts suppressed.
|
|
9933
|
-
// turn (a
|
|
9934
|
-
// destination the turn's delivered rounds
|
|
9935
|
-
// round's prefixes don't count, and the
|
|
9936
|
-
// deliveries needn't have run. The trigger channel
|
|
9937
|
-
// explicit mode's typing indicator shows.
|
|
10976
|
+
// so a fresh turn's stream starts suppressed. A later stream of the
|
|
10977
|
+
// turn (a resume, a retry, a re-inference, a capacity-deferred
|
|
10978
|
+
// compile) starts at the destination the turn's delivered rounds
|
|
10979
|
+
// have set: a private round's prefixes don't count, and the
|
|
10980
|
+
// gateway's queued deliveries needn't have run. The trigger channel
|
|
10981
|
+
// is only where explicit mode's typing indicator shows.
|
|
9938
10982
|
initialTarget: turnProseRouting === 'explicit'
|
|
9939
|
-
?
|
|
10983
|
+
? streamRouting.destination
|
|
9940
10984
|
: typingChannel,
|
|
9941
10985
|
resolve: (spec) => {
|
|
9942
10986
|
const r = this.channelRegistry.resolveProseTarget(spec);
|
|
@@ -10476,7 +11520,7 @@ export class AgentFramework {
|
|
|
10476
11520
|
.filter((block) => block.type === 'text')
|
|
10477
11521
|
.map((block) => block.text)
|
|
10478
11522
|
.join('\n'),
|
|
10479
|
-
}, streamRouting
|
|
11523
|
+
}, streamRouting);
|
|
10480
11524
|
this.emitTrace({
|
|
10481
11525
|
type: 'inference:completed',
|
|
10482
11526
|
agentName: agent.name,
|
|
@@ -10753,13 +11797,11 @@ export class AgentFramework {
|
|
|
10753
11797
|
}
|
|
10754
11798
|
}
|
|
10755
11799
|
else {
|
|
10756
|
-
//
|
|
10757
|
-
//
|
|
10758
|
-
//
|
|
10759
|
-
//
|
|
10760
|
-
//
|
|
10761
|
-
// between segments — and a turn that narrated live into one
|
|
10762
|
-
// channel must not land its postscript in another.
|
|
11800
|
+
// The turn's own locus, read from this stream's routing
|
|
11801
|
+
// state. This dispatch runs AFTER the agent is idle (see PR
|
|
11802
|
+
// #32 note below), so a next turn may already have started
|
|
11803
|
+
// with routing of its own, and a turn that narrated live into
|
|
11804
|
+
// one channel must not land its postscript in another.
|
|
10763
11805
|
const locus = resolveTurnLocus();
|
|
10764
11806
|
console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${locus ?? '(default)'}`);
|
|
10765
11807
|
// Deliver sequentially (await each) so the segments land in order.
|
|
@@ -10838,7 +11880,7 @@ export class AgentFramework {
|
|
|
10838
11880
|
stopReason: 'exhausted',
|
|
10839
11881
|
speech: '',
|
|
10840
11882
|
error: 'Turn abandoned by operator quiesce',
|
|
10841
|
-
}, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting
|
|
11883
|
+
}, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting : undefined);
|
|
10842
11884
|
this.emitTrace({
|
|
10843
11885
|
type: 'inference:aborted',
|
|
10844
11886
|
agentName: agent.name,
|
|
@@ -10877,8 +11919,8 @@ export class AgentFramework {
|
|
|
10877
11919
|
this.settleAgent(agent.name, {
|
|
10878
11920
|
stopReason: 'exhausted',
|
|
10879
11921
|
speech: '',
|
|
10880
|
-
|
|
10881
|
-
}, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting
|
|
11922
|
+
...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
|
|
11923
|
+
}, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting : undefined);
|
|
10882
11924
|
this.emitTrace({
|
|
10883
11925
|
type: 'inference:exhausted',
|
|
10884
11926
|
agentName: agent.name,
|
|
@@ -10936,7 +11978,7 @@ export class AgentFramework {
|
|
|
10936
11978
|
stopReason: 'exhausted',
|
|
10937
11979
|
speech: '',
|
|
10938
11980
|
error: 'Turn abandoned by operator quiesce',
|
|
10939
|
-
}, streamRouting
|
|
11981
|
+
}, streamRouting);
|
|
10940
11982
|
}
|
|
10941
11983
|
this.emitTrace({
|
|
10942
11984
|
type: 'inference:aborted',
|
|
@@ -10981,7 +12023,7 @@ export class AgentFramework {
|
|
|
10981
12023
|
stopReason: 'exhausted',
|
|
10982
12024
|
speech: '',
|
|
10983
12025
|
error: `Stream aborted: ${reason}`,
|
|
10984
|
-
}, streamRouting
|
|
12026
|
+
}, streamRouting);
|
|
10985
12027
|
this.emitTrace({
|
|
10986
12028
|
type: 'inference:exhausted',
|
|
10987
12029
|
agentName: agent.name,
|
|
@@ -11008,12 +12050,9 @@ export class AgentFramework {
|
|
|
11008
12050
|
break;
|
|
11009
12051
|
}
|
|
11010
12052
|
case 'usage': {
|
|
12053
|
+
// The stream's accumulated fresh input, which is what the
|
|
12054
|
+
// maxStreamTokens budget checks.
|
|
11011
12055
|
agent.lastStreamInputTokens = event.usage.inputTokens;
|
|
11012
|
-
agent.lastStreamRealInputTokens =
|
|
11013
|
-
(event.usage.inputTokens ?? 0) +
|
|
11014
|
-
(event.usage.cacheCreationTokens ?? 0) +
|
|
11015
|
-
(event.usage.cacheReadTokens ?? 0);
|
|
11016
|
-
agent.lastStreamOutputTokens = event.usage.outputTokens ?? 0;
|
|
11017
12056
|
// Closed-loop estimator calibration (2026-07-12). Sample the REAL
|
|
11018
12057
|
// prefix size of THIS API call (fresh + cache write + cache read)
|
|
11019
12058
|
// and hand it to the context strategy, which accepts exactly one
|
|
@@ -11037,11 +12076,36 @@ export class AgentFramework {
|
|
|
11037
12076
|
};
|
|
11038
12077
|
previousUsage = cumulativeUsage;
|
|
11039
12078
|
outputTokensSinceStamp += perCallUsage.outputTokens;
|
|
12079
|
+
const realTotal = perCallUsage.inputTokens +
|
|
12080
|
+
perCallUsage.cacheCreationTokens +
|
|
12081
|
+
perCallUsage.cacheReadTokens;
|
|
12082
|
+
// The physical-window projection adds the next two fields as the
|
|
12083
|
+
// prior round's real input and output, so they take this call's
|
|
12084
|
+
// share, never the event's own figures: the event is the stream's
|
|
12085
|
+
// running total, which after k calls holds k prompts and would
|
|
12086
|
+
// restart a stream whose next request still fits. Every real call
|
|
12087
|
+
// has a prompt, and a call that ends at a tool boundary wrote
|
|
12088
|
+
// output (its tool_use), so a 0 in either is a count the adapter
|
|
12089
|
+
// left at 0, not an empty call. A call that reported no prompt is
|
|
12090
|
+
// unreported: the fields stay the last call that reported one,
|
|
12091
|
+
// which is what the provider billed, and its own output joins the
|
|
12092
|
+
// growth since that call (lastStreamUnreportedTokens). An output
|
|
12093
|
+
// of 0 counts as maxTokens, the bound on it, in either case, so
|
|
12094
|
+
// the projection errs toward a restart. That holds once a call on
|
|
12095
|
+
// the stream has reported a prompt: the projection runs only then.
|
|
12096
|
+
const callOutputTokens = perCallUsage.outputTokens > 0
|
|
12097
|
+
? perCallUsage.outputTokens
|
|
12098
|
+
: agent.maxTokens;
|
|
12099
|
+
if (realTotal > 0) {
|
|
12100
|
+
agent.lastStreamRealInputTokens = realTotal;
|
|
12101
|
+
agent.lastStreamOutputTokens = callOutputTokens;
|
|
12102
|
+
agent.lastStreamUnreportedTokens = 0;
|
|
12103
|
+
}
|
|
12104
|
+
else {
|
|
12105
|
+
agent.lastStreamUnreportedTokens += callOutputTokens;
|
|
12106
|
+
}
|
|
11040
12107
|
const strat = agent.getContextManager?.()?.getStrategy?.();
|
|
11041
12108
|
try {
|
|
11042
|
-
const realTotal = perCallUsage.inputTokens +
|
|
11043
|
-
perCallUsage.cacheCreationTokens +
|
|
11044
|
-
perCallUsage.cacheReadTokens;
|
|
11045
12109
|
strat?.reportRealInputTokens?.(realTotal);
|
|
11046
12110
|
}
|
|
11047
12111
|
catch { /* calibration is best-effort */ }
|
|
@@ -11130,8 +12194,8 @@ export class AgentFramework {
|
|
|
11130
12194
|
this.settleAgent(agent.name, {
|
|
11131
12195
|
stopReason: 'exhausted',
|
|
11132
12196
|
speech: '',
|
|
11133
|
-
|
|
11134
|
-
}, streamRouting
|
|
12197
|
+
...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
|
|
12198
|
+
}, streamRouting);
|
|
11135
12199
|
this.emitTrace({
|
|
11136
12200
|
type: 'inference:exhausted',
|
|
11137
12201
|
agentName: agent.name,
|
|
@@ -11336,6 +12400,11 @@ export class AgentFramework {
|
|
|
11336
12400
|
* the operator's call; the princess precedent was disclosed first.
|
|
11337
12401
|
*/
|
|
11338
12402
|
async puppetToolCall(agentName, toolName, input) {
|
|
12403
|
+
this.refuseOperatorAfterStop('puppet');
|
|
12404
|
+
return this.trackOperatorOperation('puppet:tool-call', () => this.runPuppetToolCall(agentName, toolName, input));
|
|
12405
|
+
}
|
|
12406
|
+
/** puppetToolCall's work, run where stop() can wait for it. */
|
|
12407
|
+
async runPuppetToolCall(agentName, toolName, input) {
|
|
11339
12408
|
const agent = this.agents.get(agentName);
|
|
11340
12409
|
if (!agent)
|
|
11341
12410
|
throw new Error(`Unknown agent: ${agentName}`);
|
|
@@ -12869,6 +13938,12 @@ export class AgentFramework {
|
|
|
12869
13938
|
opts.placement.agent = agent.name;
|
|
12870
13939
|
opts.placement.messageId = stored;
|
|
12871
13940
|
}
|
|
13941
|
+
// Stored while none of the agent's turns is alive, or assembled for the
|
|
13942
|
+
// start now beginning: no tool boundary will weigh it.
|
|
13943
|
+
if (!this.activeTurnTokens.has(agent.name) || opts?.bypassDeferralFor === agent.name) {
|
|
13944
|
+
this.noteArrival(agent.name, metadata, stored);
|
|
13945
|
+
}
|
|
13946
|
+
this.noteForOtherResidents(agent, metadata, stored);
|
|
12872
13947
|
return stored;
|
|
12873
13948
|
}
|
|
12874
13949
|
/**
|
|
@@ -12910,7 +13985,7 @@ export class AgentFramework {
|
|
|
12910
13985
|
if (!agent) {
|
|
12911
13986
|
throw new Error('No agents configured');
|
|
12912
13987
|
}
|
|
12913
|
-
agent.getContextManager()
|
|
13988
|
+
removeStoredMessage(agent.getContextManager(), id);
|
|
12914
13989
|
}
|
|
12915
13990
|
getMessage(id) {
|
|
12916
13991
|
const agent = this.primaryAgentName
|
|
@@ -13322,31 +14397,56 @@ export class AgentFramework {
|
|
|
13322
14397
|
// this turn — surface it on the ops-alert pipeline (ops:alert trace +
|
|
13323
14398
|
// webhook → fleet-watch) immediately, not only at the hard-down streak.
|
|
13324
14399
|
// The per-(agent,kind) cooldown throttles repeats; failures.log already
|
|
13325
|
-
// has the per-exhaustion record from logFailure above.
|
|
13326
|
-
|
|
13327
|
-
|
|
13328
|
-
|
|
13329
|
-
|
|
13330
|
-
|
|
13331
|
-
|
|
14400
|
+
// has the per-exhaustion record from logFailure above. It says whether
|
|
14401
|
+
// the drain breaker kicks, decided where the kick is. What ends drains is
|
|
14402
|
+
// stop(), not a framework that was never started: one run with
|
|
14403
|
+
// runUntilIdle() has no maintenance timer, so a drain is its recovery.
|
|
14404
|
+
const drainState = !overBudget ? undefined
|
|
14405
|
+
: !agent ? 'unavailable (no agent handle)'
|
|
14406
|
+
: this.providerAdmissionClosed ? 'not kicked: the framework is stopping'
|
|
14407
|
+
: this.surgeryHold ? 'not kicked: a surgery holds the store'
|
|
14408
|
+
: this.overBudgetDrainInFlight.has(agentName) ? 'already draining'
|
|
14409
|
+
: 'kicking';
|
|
14410
|
+
if (agent && drainState === 'kicking') {
|
|
13332
14411
|
this.overBudgetDrainInFlight.add(agentName);
|
|
13333
|
-
|
|
14412
|
+
// Like a maintenance pass: none starts once stop() has begun, it starts
|
|
14413
|
+
// no tick while a surgery holds the store, and a surgery, a
|
|
14414
|
+
// switchBranch or stop() waits for the tick in flight. It joins the
|
|
14415
|
+
// drains they wait for before the alert is raised and before its body
|
|
14416
|
+
// runs, so the alert's listeners find it: one that begins stop() or a
|
|
14417
|
+
// surgery waits for it, and its body then ticks none and says why. So
|
|
14418
|
+
// does a stop() made from inside its first tick, before that tick's
|
|
14419
|
+
// first await.
|
|
14420
|
+
const drain = Promise.resolve().then(async () => {
|
|
13334
14421
|
let ticks = 0;
|
|
13335
14422
|
try {
|
|
13336
14423
|
const cm = agent.getContextManager();
|
|
13337
|
-
while (ticks < 8) {
|
|
14424
|
+
while (ticks < 8 && !this.providerAdmissionClosed && !this.surgeryHold) {
|
|
13338
14425
|
await cm.tick();
|
|
13339
14426
|
ticks++;
|
|
13340
14427
|
}
|
|
13341
|
-
|
|
14428
|
+
const stopped = ticks >= 8 ? '' : this.providerAdmissionClosed ? ', stopped as the framework stops' : ', stopped while a surgery holds the store';
|
|
14429
|
+
console.error(`[inference-failed] drain kicked for ${agentName} (OverBudget breaker, ${ticks} ticks${stopped})`);
|
|
13342
14430
|
}
|
|
13343
14431
|
catch (err) {
|
|
13344
|
-
|
|
14432
|
+
if (err instanceof MaintenanceWithdrawnError) {
|
|
14433
|
+
console.error(`[inference-failed] drain kicked for ${agentName} (OverBudget breaker, ${ticks} ticks, stopped: ${err.message})`);
|
|
14434
|
+
}
|
|
14435
|
+
else {
|
|
14436
|
+
console.error(`[inference-failed] drain kick failed for ${agentName} after ${ticks} ticks: ${describeFailureWithFrames(err)}`);
|
|
14437
|
+
}
|
|
13345
14438
|
}
|
|
13346
14439
|
finally {
|
|
13347
14440
|
this.overBudgetDrainInFlight.delete(agentName);
|
|
13348
14441
|
}
|
|
13349
|
-
})
|
|
14442
|
+
});
|
|
14443
|
+
this.overBudgetDrains.add(drain);
|
|
14444
|
+
void drain.finally(() => { this.overBudgetDrains.delete(drain); });
|
|
14445
|
+
}
|
|
14446
|
+
if (overBudget) {
|
|
14447
|
+
this.opsAlert('context-refusal', agentName, `compile refused (${errorType ?? 'unclassified'}): ${boundFailureText(wholeReason, FAILURE_SUMMARY_MAX_CHARS)} — ` +
|
|
14448
|
+
`drain breaker ${drainState}; ` +
|
|
14449
|
+
`if this persists, raise contextBudgetTokens or overBudgetGraceRatio.`, { skipLog: true });
|
|
13350
14450
|
}
|
|
13351
14451
|
// (3) Hard-down escalation on repeated identical failure.
|
|
13352
14452
|
if (streak >= this.inferenceFailureEscalationThreshold) {
|
|
@@ -13473,13 +14573,22 @@ export class AgentFramework {
|
|
|
13473
14573
|
// belt-and-suspenders fallback.
|
|
13474
14574
|
homeChannelResolver: (agentName) => this.conversationAgentHomes.get(agentName)
|
|
13475
14575
|
?? this.conversationRouter?.channelForAgent(agentName),
|
|
13476
|
-
//
|
|
13477
|
-
//
|
|
13478
|
-
//
|
|
13479
|
-
//
|
|
13480
|
-
//
|
|
13481
|
-
//
|
|
13482
|
-
|
|
14576
|
+
// A single TRUNK agent's channel for its CURRENT turn (item-3 redux):
|
|
14577
|
+
// its locus once the turn has one, a re-pin's or a channel_open's
|
|
14578
|
+
// included, else the channel that triggered the turn. connectome-host
|
|
14579
|
+
// runs every agent as a trunk (it never exposes conversation forks), so
|
|
14580
|
+
// without this a reply falls back to the process-global
|
|
14581
|
+
// most-recent-inbound locus and a concurrent message in another
|
|
14582
|
+
// channel hijacks it. An opening start resolves the turn's locus from
|
|
14583
|
+
// it (the trigger channel then), and the channel context names it, so
|
|
14584
|
+
// a later start of the turn is told where its unprefixed speech lands
|
|
14585
|
+
// unless a hybrid `>>>` destination takes it. Empty for a heartbeat /
|
|
14586
|
+
// no-trigger turn until its locus is resolved, so that resolution
|
|
14587
|
+
// takes the global fallback; the turn then keeps that channel.
|
|
14588
|
+
activeChannelResolver: (agentName) => {
|
|
14589
|
+
const routing = this.turnRouting.get(agentName);
|
|
14590
|
+
return routing?.locus ?? routing?.triggerChannel;
|
|
14591
|
+
},
|
|
13483
14592
|
// A text-only turn whose speech couldn't be delivered must not vanish
|
|
13484
14593
|
// silently: record a `[discord-send-failed]` marker in chronicle so the
|
|
13485
14594
|
// agent sees, on her next turn, that her reply never reached the human.
|
|
@@ -13570,15 +14679,17 @@ export class AgentFramework {
|
|
|
13570
14679
|
}
|
|
13571
14680
|
// Fail-open: log and continue with remaining servers
|
|
13572
14681
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
13573
|
-
console.error(`Failed to connect MCPL server "${config.id}"
|
|
14682
|
+
console.error(`Failed to connect MCPL server "${config.id}": ${err.message}${withStderr(err)}`);
|
|
13574
14683
|
// Reaching this catch means no stub was created, so nothing will
|
|
13575
14684
|
// retry — except in the edge case where addServer succeeded (with a
|
|
13576
14685
|
// reconnecting stub) and a later setup step threw; config.reconnect
|
|
13577
14686
|
// is the best cheap approximation of that.
|
|
14687
|
+
const stderrTail = stderrTailOf(err);
|
|
13578
14688
|
this.emitTrace({
|
|
13579
14689
|
type: 'mcpl:server-connect-failed',
|
|
13580
14690
|
serverId: config.id,
|
|
13581
14691
|
error: err.message,
|
|
14692
|
+
...(stderrTail === undefined ? {} : { stderrTail }),
|
|
13582
14693
|
attempt: 0,
|
|
13583
14694
|
willRetry: config.reconnect === true,
|
|
13584
14695
|
});
|
|
@@ -13603,9 +14714,19 @@ export class AgentFramework {
|
|
|
13603
14714
|
/**
|
|
13604
14715
|
* Reconcile the durable ledger with Chronicle, then deliver every server's
|
|
13605
14716
|
* work. A drain still running is waited for and then followed by a fresh
|
|
13606
|
-
* pass (drainForGeneration).
|
|
14717
|
+
* pass (drainForGeneration). Like the other operator entry points, it's
|
|
14718
|
+
* refused once stop() has begun, and stop() waits for one already running.
|
|
14719
|
+
* The framework's own branch moves start their delivery without awaiting
|
|
14720
|
+
* it (startDiscordAwarenessDelivery).
|
|
13607
14721
|
*/
|
|
13608
|
-
async syncDiscordAwarenessMarkers(
|
|
14722
|
+
async syncDiscordAwarenessMarkers(onlyServerId) {
|
|
14723
|
+
this.refuseOperatorAfterStop('awareness sync');
|
|
14724
|
+
return this.trackOperatorOperation('awareness:sync', () => this.runDiscordAwarenessSync(onlyServerId));
|
|
14725
|
+
}
|
|
14726
|
+
/** The sync's work. It installs the MCPL data-plane gate before its first
|
|
14727
|
+
* await, so a caller that starts it while holding the store puts the gate
|
|
14728
|
+
* up before releasing the store. */
|
|
14729
|
+
async runDiscordAwarenessSync(_onlyServerId) {
|
|
13609
14730
|
if (!this.discordAwarenessOutbox)
|
|
13610
14731
|
return;
|
|
13611
14732
|
// A targeted retry may discover pending work for other Discord servers
|
|
@@ -13641,10 +14762,7 @@ export class AgentFramework {
|
|
|
13641
14762
|
throw new Error(`Cannot safely resume Discord suppression ${batch.id}: interval ` +
|
|
13642
14763
|
`${interval.fromId}..${interval.toId} is only partially present`);
|
|
13643
14764
|
}
|
|
13644
|
-
|
|
13645
|
-
cm.removeMessage(from.id);
|
|
13646
|
-
else
|
|
13647
|
-
cm.removeMessages(from.id, to.id);
|
|
14765
|
+
removeStoredMessages(cm, from, to);
|
|
13648
14766
|
}
|
|
13649
14767
|
this.discordAwarenessOutbox.activate(batch.id);
|
|
13650
14768
|
console.error(`[discord-awareness] resumed suppression batch ${batch.id}`);
|
|
@@ -13903,6 +15021,15 @@ export class AgentFramework {
|
|
|
13903
15021
|
error: `Discord awareness accounting unhealthy: ${err.message}`,
|
|
13904
15022
|
});
|
|
13905
15023
|
}
|
|
15024
|
+
// A stopping host closes these connections itself, so recycling them now
|
|
15025
|
+
// would start reconnects only for stop() to close them again. Release the
|
|
15026
|
+
// barrier instead, as a completed one is released: nothing waits on a
|
|
15027
|
+
// rejected barrier, and unless the host is quiesced, what the pause held
|
|
15028
|
+
// back reaches the listeners' stopping refusal.
|
|
15029
|
+
if (this.queue?.isClosed) {
|
|
15030
|
+
this.completeMcplDataPlaneGate(barrier);
|
|
15031
|
+
return;
|
|
15032
|
+
}
|
|
13906
15033
|
await Promise.all(connections.map(async (connection) => {
|
|
13907
15034
|
await connection.reconnectAfterFailure().catch((closeError) => {
|
|
13908
15035
|
console.error(`[discord-awareness] could not recycle unhealthy connection ${connection.id}:`, closeError instanceof Error ? closeError.message : closeError);
|
|
@@ -14374,6 +15501,10 @@ export class AgentFramework {
|
|
|
14374
15501
|
* connection/handshake fails.
|
|
14375
15502
|
*/
|
|
14376
15503
|
async connectMcplServer(config) {
|
|
15504
|
+
this.refuseOperatorAfterStop('connect MCPL server');
|
|
15505
|
+
return this.trackOperatorOperation('mcpl:connect', () => this.runConnectMcplServer(config));
|
|
15506
|
+
}
|
|
15507
|
+
async runConnectMcplServer(config) {
|
|
14377
15508
|
// Lazily bring up the MCPL subsystem — a framework that started with zero
|
|
14378
15509
|
// configured servers can still deploy its first one at runtime.
|
|
14379
15510
|
if (!this.mcplServerRegistry || !this.mcplHostCapabilities) {
|
|
@@ -14402,6 +15533,10 @@ export class AgentFramework {
|
|
|
14402
15533
|
* No-op-ish if the server is not connected (still clears routing state).
|
|
14403
15534
|
*/
|
|
14404
15535
|
async disconnectMcplServer(id) {
|
|
15536
|
+
this.refuseOperatorAfterStop('disconnect MCPL server');
|
|
15537
|
+
return this.trackOperatorOperation('mcpl:disconnect', () => this.runDisconnectMcplServer(id));
|
|
15538
|
+
}
|
|
15539
|
+
async runDisconnectMcplServer(id) {
|
|
14405
15540
|
if (!this.mcplServerRegistry) {
|
|
14406
15541
|
throw new Error('MCPL subsystem is not initialized');
|
|
14407
15542
|
}
|
|
@@ -14428,6 +15563,10 @@ export class AgentFramework {
|
|
|
14428
15563
|
* failures. Throws if the server has no stored config and none is given.
|
|
14429
15564
|
*/
|
|
14430
15565
|
async restartMcplServer(id, newConfig) {
|
|
15566
|
+
this.refuseOperatorAfterStop('restart MCPL server');
|
|
15567
|
+
return this.trackOperatorOperation('mcpl:restart', () => this.runRestartMcplServer(id, newConfig));
|
|
15568
|
+
}
|
|
15569
|
+
async runRestartMcplServer(id, newConfig) {
|
|
14431
15570
|
const config = newConfig ?? this.mcplServerConfigs.get(id);
|
|
14432
15571
|
if (!config) {
|
|
14433
15572
|
throw new Error(`MCPL server "${id}" is not configured`);
|
|
@@ -14438,8 +15577,8 @@ export class AgentFramework {
|
|
|
14438
15577
|
// refuses the restart and leaves the running child up, whatever the
|
|
14439
15578
|
// server's baseline or transport. (No outbox configured: nothing to read.)
|
|
14440
15579
|
this.getPlacedReactionBaseline();
|
|
14441
|
-
await this.
|
|
14442
|
-
await this.
|
|
15580
|
+
await this.runDisconnectMcplServer(id);
|
|
15581
|
+
await this.runConnectMcplServer(config);
|
|
14443
15582
|
}
|
|
14444
15583
|
/**
|
|
14445
15584
|
* List configured MCPL servers with live connection status and the number
|
|
@@ -14650,8 +15789,8 @@ export class AgentFramework {
|
|
|
14650
15789
|
if (this.discordAwarenessBarrier)
|
|
14651
15790
|
await this.awaitDiscordAwarenessBarriers();
|
|
14652
15791
|
// A stopping host runs no host command: refused like the requests
|
|
14653
|
-
// above, before its handler runs.
|
|
14654
|
-
if (this.
|
|
15792
|
+
// above, before its handler runs, from the moment stop() has begun.
|
|
15793
|
+
if (this.stopHasBegun()) {
|
|
14655
15794
|
this.refuseServerItem('host/command', connection.id, answer, { kind: 'refused' });
|
|
14656
15795
|
return;
|
|
14657
15796
|
}
|
|
@@ -14729,6 +15868,7 @@ export class AgentFramework {
|
|
|
14729
15868
|
type: 'mcpl:server-connect-failed',
|
|
14730
15869
|
serverId: connection.id,
|
|
14731
15870
|
error: params.error,
|
|
15871
|
+
...(params.stderrTail === undefined ? {} : { stderrTail: params.stderrTail }),
|
|
14732
15872
|
attempt: params.attempt,
|
|
14733
15873
|
willRetry: connection.willReconnect,
|
|
14734
15874
|
});
|
|
@@ -14738,6 +15878,7 @@ export class AgentFramework {
|
|
|
14738
15878
|
type: 'mcpl:server-connect-failed',
|
|
14739
15879
|
serverId: connection.id,
|
|
14740
15880
|
error: params.error,
|
|
15881
|
+
...(params.stderrTail === undefined ? {} : { stderrTail: params.stderrTail }),
|
|
14741
15882
|
attempt: params.attempt,
|
|
14742
15883
|
willRetry: connection.willReconnect,
|
|
14743
15884
|
});
|
|
@@ -14746,7 +15887,12 @@ export class AgentFramework {
|
|
|
14746
15887
|
// 5 failed attempts ≈ a few minutes of outage. Throttled per
|
|
14747
15888
|
// (serverId, kind), so a long outage re-posts every ~15 min.
|
|
14748
15889
|
if (params.attempt >= 5) {
|
|
14749
|
-
this.opsAlert('mcpl-down', connection.id,
|
|
15890
|
+
this.opsAlert('mcpl-down', connection.id,
|
|
15891
|
+
// Not the server's stderr (params.stderrTail): the webhook posts
|
|
15892
|
+
// outside the process, and a server's stderr can hold a credential
|
|
15893
|
+
// its configuration gives it, such as a token, which nothing here
|
|
15894
|
+
// redacts.
|
|
15895
|
+
`MCPL server unreachable (attempt ${params.attempt}): ${params.error}`);
|
|
14750
15896
|
}
|
|
14751
15897
|
});
|
|
14752
15898
|
// A late response to a tools/call that already timed out, carrying
|
|
@@ -14782,7 +15928,7 @@ export class AgentFramework {
|
|
|
14782
15928
|
// on it would erase every checkpoint tree on an ordinary host restart while
|
|
14783
15929
|
// a SIGKILL preserves them. Permanent removal is owned solely by
|
|
14784
15930
|
// disconnectMcplServer, which deletes the trees explicitly.
|
|
14785
|
-
connection.on('close', (code, signal) => {
|
|
15931
|
+
connection.on('close', (code, signal, info) => {
|
|
14786
15932
|
this.featureSetManager?.removeServer(connection.id);
|
|
14787
15933
|
// RFC-008: a closed provider's class hints are stale — on reconnect it
|
|
14788
15934
|
// may class its tools differently. Until the next tools/list its tools
|
|
@@ -14793,6 +15939,9 @@ export class AgentFramework {
|
|
|
14793
15939
|
serverId: connection.id,
|
|
14794
15940
|
code: code ?? null,
|
|
14795
15941
|
signal: signal ?? null,
|
|
15942
|
+
// The transport's own words only: a WebSocket server's close text
|
|
15943
|
+
// stays off the trace.
|
|
15944
|
+
reason: info?.reason !== undefined && !info.reasonFromPeer ? boundFailureText(info.reason) : null,
|
|
14796
15945
|
willReconnect: connection.willReconnect,
|
|
14797
15946
|
});
|
|
14798
15947
|
this.emitTrace({ type: 'module:removed', moduleName: `mcpl:${connection.id}` });
|
|
@@ -15035,6 +16184,9 @@ export class AgentFramework {
|
|
|
15035
16184
|
this.emitTrace({ type: 'tool:started', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, input: call.input });
|
|
15036
16185
|
const startTime = Date.now();
|
|
15037
16186
|
const args = (call.input && typeof call.input === 'object') ? call.input : {};
|
|
16187
|
+
// The turn that sends: a send's engaged channel is recorded on it, even
|
|
16188
|
+
// when the send's result, or the open that follows, comes after the turn.
|
|
16189
|
+
const sendingTurn = SEND_ENGAGEMENT_TOOLS.has(toolName) ? this.turnRouting.get(agentName) : undefined;
|
|
15038
16190
|
// Build state params for stateful tools (Step 8).
|
|
15039
16191
|
// A tools/list attribution selects that exact stateful set. Untagged tools
|
|
15040
16192
|
// retain the host-managed or unique server-managed fallback, but never guess
|
|
@@ -15122,12 +16274,7 @@ export class AgentFramework {
|
|
|
15122
16274
|
// the microtask queue, i.e. before the tool-result event
|
|
15123
16275
|
// that evaluates injections.
|
|
15124
16276
|
if (status !== 'unknown-channel' && status !== 'ambiguous') {
|
|
15125
|
-
|
|
15126
|
-
if (!engaged) {
|
|
15127
|
-
engaged = new Set();
|
|
15128
|
-
this.turnEngagedChannels.set(agentName, engaged);
|
|
15129
|
-
}
|
|
15130
|
-
engaged.add(channelId ?? target);
|
|
16277
|
+
sendingTurn?.engaged.add(channelId ?? target);
|
|
15131
16278
|
}
|
|
15132
16279
|
if (status === 'opened') {
|
|
15133
16280
|
console.error(`[channel] ${agentName}: explicit ${toolName} into closed channel ${target} — opened (send implies engagement)`);
|
|
@@ -15233,6 +16380,15 @@ export class AgentFramework {
|
|
|
15233
16380
|
}
|
|
15234
16381
|
this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
|
|
15235
16382
|
const startTime = Date.now();
|
|
16383
|
+
// The turn that called: a channel_open's result records its trigger
|
|
16384
|
+
// channel there, however late it arrives, and moves its locus while the
|
|
16385
|
+
// turn is still open, even once another start's routing is the agent's
|
|
16386
|
+
// live one. A silent turn's open moves no locus: no silent start or
|
|
16387
|
+
// resume registers its routing state as the open turn (openLogical), and
|
|
16388
|
+
// a non-silent resume that does, a budget restart batched with an
|
|
16389
|
+
// ordinary wake, starts only once its over-budget boundary has every
|
|
16390
|
+
// result of the round.
|
|
16391
|
+
const callingTurn = this.routingOf(agentName);
|
|
15236
16392
|
this.channelRegistry.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName })
|
|
15237
16393
|
.then((result) => {
|
|
15238
16394
|
const durationMs = Date.now() - startTime;
|
|
@@ -15240,10 +16396,10 @@ export class AgentFramework {
|
|
|
15240
16396
|
// A successful channel_open plants the agent's feet in the opened
|
|
15241
16397
|
// channel. The agent's own deliberate open is the strongest "my next
|
|
15242
16398
|
// words go here" signal in the system — stronger than any injection —
|
|
15243
|
-
// so it MOVES the current turn's
|
|
15244
|
-
//
|
|
15245
|
-
//
|
|
15246
|
-
//
|
|
16399
|
+
// so it MOVES the current turn's locus (TurnRouting.locus), not just
|
|
16400
|
+
// its trigger channel. (The original 2026-07-21 Aria fix set only
|
|
16401
|
+
// the trigger channel, which resolveLocus reads at the NEXT turn's
|
|
16402
|
+
// freeze — the turn-frozen refactor had silently
|
|
15247
16403
|
// regressed the "reply right after opening" case; found while
|
|
15248
16404
|
// mapping the routing logic 2026-07-31, antra-ratified fix.) The
|
|
15249
16405
|
// announcement rides THIS TOOL RESULT — model-requested content,
|
|
@@ -15254,10 +16410,19 @@ export class AgentFramework {
|
|
|
15254
16410
|
const opened = result.data?.channelId
|
|
15255
16411
|
?? call.input?.channelId;
|
|
15256
16412
|
if (opened) {
|
|
15257
|
-
|
|
16413
|
+
callingTurn.triggerChannel = opened;
|
|
15258
16414
|
const openerAgent = this.agents.get(agentName);
|
|
15259
|
-
|
|
15260
|
-
|
|
16415
|
+
// The move is one this result tells the agent, so it needs the
|
|
16416
|
+
// turn that called still open. A result that lands after the turn
|
|
16417
|
+
// settled is dropped, so the agent never reads it, whether or not
|
|
16418
|
+
// a later start has replaced the turn's routing state yet: the
|
|
16419
|
+
// turn's locus stays where the agent was last told, for a resume
|
|
16420
|
+
// that opens the turn again, and so does the announce baseline,
|
|
16421
|
+
// which is the agent's across turns.
|
|
16422
|
+
if (openerAgent
|
|
16423
|
+
&& (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')
|
|
16424
|
+
&& this.openLogical.get(agentName) === callingTurn) {
|
|
16425
|
+
callingTurn.locus = opened;
|
|
15261
16426
|
this.lastAnnouncedLocus.set(agentName, opened);
|
|
15262
16427
|
result = {
|
|
15263
16428
|
...result,
|
|
@@ -15521,7 +16686,7 @@ export class AgentFramework {
|
|
|
15521
16686
|
}
|
|
15522
16687
|
}
|
|
15523
16688
|
else {
|
|
15524
|
-
const locus = this.
|
|
16689
|
+
const locus = this.turnRouting.get(agentName)?.locus ?? null;
|
|
15525
16690
|
this.channelRegistry.routeSpeech(agentName, text, locus).catch((err) => {
|
|
15526
16691
|
console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
|
|
15527
16692
|
});
|
|
@@ -16355,9 +17520,10 @@ export class AgentFramework {
|
|
|
16355
17520
|
// Conversation identity = the agent (a trunk agent IS its own conversation;
|
|
16356
17521
|
// forks get their own agent). The turn's channel LOCUS — the "proper
|
|
16357
17522
|
// conversation tracking" this once flagged as a TODO — is now tracked
|
|
16358
|
-
//
|
|
16359
|
-
//
|
|
16360
|
-
//
|
|
17523
|
+
// on the turn's routing state (TurnRouting.locus, from its trigger
|
|
17524
|
+
// channel) and surfaced to the agent via buildChannelContext
|
|
17525
|
+
// (channels.defaultOutgoing) below, so the agent is told the same
|
|
17526
|
+
// channel its speech will route to (item-3 redux).
|
|
16361
17527
|
conversationId: agent.name,
|
|
16362
17528
|
turnIndex: 0, // Simplified; needs per-conversation counter TODO
|
|
16363
17529
|
userMessage: null, // Could extract from trigger context
|