@animalabs/agent-framework 0.21.1 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -4
- package/dist/src/agent.d.ts +17 -4
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +105 -43
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +41 -4
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +22 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +64 -9
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +4 -0
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +215 -38
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/framework.d.ts +569 -59
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +3091 -542
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +21 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +40 -7
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +7 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +26 -3
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +28 -1
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +17 -0
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/feature-set-manager.d.ts +11 -0
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
- package/dist/src/mcpl/feature-set-manager.js +31 -7
- package/dist/src/mcpl/feature-set-manager.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +4 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +1 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +27 -6
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
- package/dist/src/mcpl/prose-stream-router.js +85 -22
- package/dist/src/mcpl/prose-stream-router.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +32 -4
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +46 -13
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +5 -3
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +41 -11
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +2 -1
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +20 -9
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +29 -6
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +57 -16
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +32 -1
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +76 -11
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts +5 -2
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +25 -7
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.d.ts +52 -2
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.js +47 -1
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
- package/dist/src/mcpl/tool-pattern-check.d.ts +51 -0
- package/dist/src/mcpl/tool-pattern-check.d.ts.map +1 -0
- package/dist/src/mcpl/tool-pattern-check.js +79 -0
- package/dist/src/mcpl/tool-pattern-check.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +15 -5
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/mcpl/visible-content.d.ts +39 -0
- package/dist/src/mcpl/visible-content.d.ts.map +1 -0
- package/dist/src/mcpl/visible-content.js +54 -0
- package/dist/src/mcpl/visible-content.js.map +1 -0
- package/dist/src/module-registry.d.ts +90 -6
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +260 -18
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/workspace/branch-intent.d.ts +72 -0
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -0
- package/dist/src/modules/workspace/branch-intent.js +83 -0
- package/dist/src/modules/workspace/branch-intent.js.map +1 -0
- package/dist/src/modules/workspace/disk-agreement.d.ts +167 -0
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -0
- package/dist/src/modules/workspace/disk-agreement.js +259 -0
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -0
- package/dist/src/modules/workspace/effects.d.ts +84 -0
- package/dist/src/modules/workspace/effects.d.ts.map +1 -0
- package/dist/src/modules/workspace/effects.js +368 -0
- package/dist/src/modules/workspace/effects.js.map +1 -0
- package/dist/src/modules/workspace/index.d.ts +155 -17
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +1101 -424
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +288 -0
- package/dist/src/modules/workspace/observe.d.ts.map +1 -0
- package/dist/src/modules/workspace/observe.js +825 -0
- package/dist/src/modules/workspace/observe.js.map +1 -0
- package/dist/src/modules/workspace/reconcile.d.ts +238 -0
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -0
- package/dist/src/modules/workspace/reconcile.js +909 -0
- package/dist/src/modules/workspace/reconcile.js.map +1 -0
- package/dist/src/modules/workspace/sync.d.ts +6 -59
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +6 -257
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +77 -10
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/operator-log.d.ts +41 -0
- 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 +216 -0
- package/dist/src/provider-waits.d.ts.map +1 -0
- package/dist/src/provider-waits.js +482 -0
- package/dist/src/provider-waits.js.map +1 -0
- package/dist/src/record-journal.d.ts +127 -0
- package/dist/src/record-journal.d.ts.map +1 -0
- package/dist/src/record-journal.js +202 -0
- package/dist/src/record-journal.js.map +1 -0
- package/dist/src/refusal-reactions.d.ts +14 -12
- package/dist/src/refusal-reactions.d.ts.map +1 -1
- package/dist/src/refusal-reactions.js +14 -12
- package/dist/src/refusal-reactions.js.map +1 -1
- package/dist/src/shutdown.d.ts +13 -0
- package/dist/src/shutdown.d.ts.map +1 -0
- package/dist/src/shutdown.js +46 -0
- package/dist/src/shutdown.js.map +1 -0
- package/dist/src/silent-heartbeat.d.ts +74 -0
- package/dist/src/silent-heartbeat.d.ts.map +1 -0
- package/dist/src/silent-heartbeat.js +77 -0
- package/dist/src/silent-heartbeat.js.map +1 -0
- package/dist/src/tool-presentation.d.ts +50 -0
- package/dist/src/tool-presentation.d.ts.map +1 -0
- package/dist/src/tool-presentation.js +235 -0
- package/dist/src/tool-presentation.js.map +1 -0
- package/dist/src/tool-result-guard.d.ts +142 -6
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +357 -36
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +13 -0
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +21 -1
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/agent.d.ts +18 -2
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +16 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +12 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +26 -4
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +87 -3
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/batched-wake-locus.test.d.ts +2 -0
- package/dist/test/batched-wake-locus.test.d.ts.map +1 -0
- package/dist/test/batched-wake-locus.test.js +166 -0
- package/dist/test/batched-wake-locus.test.js.map +1 -0
- package/dist/test/code-execution.test.js +386 -2
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/component-defaults.test.d.ts +2 -0
- package/dist/test/component-defaults.test.d.ts.map +1 -0
- package/dist/test/component-defaults.test.js +54 -0
- package/dist/test/component-defaults.test.js.map +1 -0
- package/dist/test/conversation-routing.test.js +57 -1
- package/dist/test/conversation-routing.test.js.map +1 -1
- package/dist/test/discord-awareness-outbox.test.js +3 -0
- package/dist/test/discord-awareness-outbox.test.js.map +1 -1
- package/dist/test/ephemeral-settle.test.js +3 -0
- package/dist/test/ephemeral-settle.test.js.map +1 -1
- package/dist/test/event-gate.test.js +118 -0
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/explicit-prose-routing.test.js +975 -0
- package/dist/test/explicit-prose-routing.test.js.map +1 -1
- package/dist/test/framework.test.js +160 -1
- package/dist/test/framework.test.js.map +1 -1
- package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -1
- package/dist/test/helpers/coalescing-fixture.js +3 -2
- package/dist/test/helpers/coalescing-fixture.js.map +1 -1
- package/dist/test/helpers/pytc-stop-rows.d.ts +11 -0
- package/dist/test/helpers/pytc-stop-rows.d.ts.map +1 -0
- package/dist/test/helpers/pytc-stop-rows.js +143 -0
- package/dist/test/helpers/pytc-stop-rows.js.map +1 -0
- package/dist/test/inference-exhaustion-rewind.test.js +142 -14
- package/dist/test/inference-exhaustion-rewind.test.js.map +1 -1
- package/dist/test/inference-failure-bounds.test.d.ts +2 -0
- package/dist/test/inference-failure-bounds.test.d.ts.map +1 -0
- package/dist/test/inference-failure-bounds.test.js +1201 -0
- package/dist/test/inference-failure-bounds.test.js.map +1 -0
- package/dist/test/late-events-after-stop.test.d.ts +2 -0
- package/dist/test/late-events-after-stop.test.d.ts.map +1 -0
- package/dist/test/late-events-after-stop.test.js +122 -0
- package/dist/test/late-events-after-stop.test.js.map +1 -0
- package/dist/test/mcpl-awareness-barrier.test.js +141 -0
- package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
- package/dist/test/mcpl-coalescing.test.js +97 -22
- package/dist/test/mcpl-coalescing.test.js.map +1 -1
- package/dist/test/mcpl-config-diagnostics.test.d.ts +2 -0
- package/dist/test/mcpl-config-diagnostics.test.d.ts.map +1 -0
- package/dist/test/mcpl-config-diagnostics.test.js +452 -0
- package/dist/test/mcpl-config-diagnostics.test.js.map +1 -0
- package/dist/test/mcpl-empty-content.test.d.ts +2 -0
- package/dist/test/mcpl-empty-content.test.d.ts.map +1 -0
- package/dist/test/mcpl-empty-content.test.js +368 -0
- package/dist/test/mcpl-empty-content.test.js.map +1 -0
- package/dist/test/mcpl-manifest-changed.test.js +2 -1
- package/dist/test/mcpl-manifest-changed.test.js.map +1 -1
- package/dist/test/mcpl-reference-fetcher.test.js +232 -4
- package/dist/test/mcpl-reference-fetcher.test.js.map +1 -1
- package/dist/test/mcpl-reference-fields.test.js +69 -2
- package/dist/test/mcpl-reference-fields.test.js.map +1 -1
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js +106 -1
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
- package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +2 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.js +288 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -0
- package/dist/test/mcpl-ws-transport.test.js +103 -0
- package/dist/test/mcpl-ws-transport.test.js.map +1 -1
- package/dist/test/ops-alert.test.js +2 -1
- package/dist/test/ops-alert.test.js.map +1 -1
- package/dist/test/placed-reaction-baseline.test.d.ts +2 -0
- package/dist/test/placed-reaction-baseline.test.d.ts.map +1 -0
- package/dist/test/placed-reaction-baseline.test.js +312 -0
- package/dist/test/placed-reaction-baseline.test.js.map +1 -0
- package/dist/test/prose-stream-router.test.js +41 -0
- package/dist/test/prose-stream-router.test.js.map +1 -1
- package/dist/test/provider-acceleration-cooldown.test.js +5 -1
- package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
- package/dist/test/provider-waits.test.d.ts +2 -0
- package/dist/test/provider-waits.test.d.ts.map +1 -0
- package/dist/test/provider-waits.test.js +2196 -0
- package/dist/test/provider-waits.test.js.map +1 -0
- package/dist/test/reaction-baseline-platform.test.d.ts +2 -0
- package/dist/test/reaction-baseline-platform.test.d.ts.map +1 -0
- package/dist/test/reaction-baseline-platform.test.js +124 -0
- package/dist/test/reaction-baseline-platform.test.js.map +1 -0
- package/dist/test/record-journal.test.d.ts +6 -0
- package/dist/test/record-journal.test.d.ts.map +1 -0
- package/dist/test/record-journal.test.js +269 -0
- package/dist/test/record-journal.test.js.map +1 -0
- package/dist/test/refusal-reaction-baseline.test.js +10 -3
- package/dist/test/refusal-reaction-baseline.test.js.map +1 -1
- package/dist/test/save-recent-image.test.js +240 -2
- package/dist/test/save-recent-image.test.js.map +1 -1
- package/dist/test/shutdown-errors.test.d.ts +2 -0
- package/dist/test/shutdown-errors.test.d.ts.map +1 -0
- package/dist/test/shutdown-errors.test.js +662 -0
- package/dist/test/shutdown-errors.test.js.map +1 -0
- package/dist/test/silent-heartbeat-rows.test.d.ts +2 -0
- package/dist/test/silent-heartbeat-rows.test.d.ts.map +1 -0
- package/dist/test/silent-heartbeat-rows.test.js +451 -0
- package/dist/test/silent-heartbeat-rows.test.js.map +1 -0
- package/dist/test/silent-heartbeat.test.js +49 -38
- package/dist/test/silent-heartbeat.test.js.map +1 -1
- package/dist/test/stop-with-work-in-flight.test.d.ts +23 -0
- package/dist/test/stop-with-work-in-flight.test.d.ts.map +1 -0
- package/dist/test/stop-with-work-in-flight.test.js +672 -0
- package/dist/test/stop-with-work-in-flight.test.js.map +1 -0
- package/dist/test/surgery-marks-receipt.test.d.ts +2 -0
- package/dist/test/surgery-marks-receipt.test.d.ts.map +1 -0
- package/dist/test/surgery-marks-receipt.test.js +540 -0
- package/dist/test/surgery-marks-receipt.test.js.map +1 -0
- package/dist/test/tool-batch-hook.test.d.ts +8 -0
- package/dist/test/tool-batch-hook.test.d.ts.map +1 -0
- package/dist/test/tool-batch-hook.test.js +275 -0
- package/dist/test/tool-batch-hook.test.js.map +1 -0
- package/dist/test/tool-presentation.test.d.ts +2 -0
- package/dist/test/tool-presentation.test.d.ts.map +1 -0
- package/dist/test/tool-presentation.test.js +268 -0
- package/dist/test/tool-presentation.test.js.map +1 -0
- package/dist/test/tool-result-guard.test.js +2545 -19
- package/dist/test/tool-result-guard.test.js.map +1 -1
- package/dist/test/tool-result-history.test.js +2 -1
- package/dist/test/tool-result-history.test.js.map +1 -1
- package/dist/test/turn-trigger-provenance.test.js +35 -0
- package/dist/test/turn-trigger-provenance.test.js.map +1 -1
- package/dist/test/unconvertible-tool-result.test.d.ts +2 -0
- package/dist/test/unconvertible-tool-result.test.d.ts.map +1 -0
- package/dist/test/unconvertible-tool-result.test.js +358 -0
- package/dist/test/unconvertible-tool-result.test.js.map +1 -0
- package/dist/test/workspace-materialize-freshness.test.d.ts +16 -0
- package/dist/test/workspace-materialize-freshness.test.d.ts.map +1 -0
- package/dist/test/workspace-materialize-freshness.test.js +252 -0
- package/dist/test/workspace-materialize-freshness.test.js.map +1 -0
- package/dist/test/workspace-three-way.test.d.ts +12 -0
- package/dist/test/workspace-three-way.test.d.ts.map +1 -0
- package/dist/test/workspace-three-way.test.js +3194 -0
- package/dist/test/workspace-three-way.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +124 -45
- package/src/api/server.ts +43 -4
- package/src/code-execution/py-runner.ts +66 -9
- package/src/code-execution/runtime-py.ts +215 -38
- package/src/framework.ts +3191 -544
- package/src/gate/event-gate.ts +59 -7
- package/src/index.ts +18 -2
- package/src/mcpl/channel-registry.ts +24 -3
- package/src/mcpl/conversation-router.ts +34 -1
- package/src/mcpl/feature-set-manager.ts +36 -11
- package/src/mcpl/index.ts +4 -0
- package/src/mcpl/prose-stream-router.ts +79 -23
- package/src/mcpl/push-coalescer.ts +62 -13
- package/src/mcpl/push-handler.ts +41 -12
- package/src/mcpl/reference-fetcher.ts +18 -8
- package/src/mcpl/references.ts +57 -13
- package/src/mcpl/server-connection.ts +95 -13
- package/src/mcpl/server-registry.ts +24 -7
- package/src/mcpl/tool-lifecycle.ts +77 -2
- package/src/mcpl/tool-pattern-check.ts +106 -0
- package/src/mcpl/types.ts +15 -5
- package/src/mcpl/visible-content.ts +57 -0
- package/src/module-registry.ts +269 -25
- package/src/modules/workspace/branch-intent.ts +123 -0
- package/src/modules/workspace/disk-agreement.ts +325 -0
- package/src/modules/workspace/effects.ts +362 -0
- package/src/modules/workspace/index.ts +1098 -446
- package/src/modules/workspace/observe.ts +865 -0
- package/src/modules/workspace/reconcile.ts +1157 -0
- package/src/modules/workspace/sync.ts +6 -336
- package/src/modules/workspace/types.ts +75 -10
- package/src/operator-log.ts +30 -0
- package/src/provider-waits.ts +523 -0
- package/src/record-journal.ts +246 -0
- package/src/refusal-reactions.ts +14 -12
- package/src/shutdown.ts +49 -0
- package/src/silent-heartbeat.ts +141 -0
- package/src/tool-presentation.ts +188 -0
- package/src/tool-result-guard.ts +384 -34
- package/src/tool-result-history.ts +25 -1
- package/src/types/agent.ts +18 -2
- package/src/types/events.ts +16 -0
- package/src/types/framework.ts +12 -0
- package/src/types/module.ts +27 -4
- package/src/types/trace.ts +94 -3
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* bookkeeping and is unit-tested without a framework.
|
|
19
19
|
*/
|
|
20
20
|
import type { McplContentBlock, PushEventResult } from './types.js';
|
|
21
|
+
import { isVisiblyEmptyContent } from './visible-content.js';
|
|
21
22
|
|
|
22
23
|
export const PUSH_COALESCING_SUPPORT = {
|
|
23
24
|
pushEvents: true, channelsIncoming: true, deferred: true, channelScopedPush: true,
|
|
@@ -36,6 +37,15 @@ export class CoalesceError extends Error {
|
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/** Content with nothing model-visible (see visible-content.ts): -32602. */
|
|
41
|
+
export class EmptyContentError extends CoalesceError {
|
|
42
|
+
readonly reason = 'empty-content';
|
|
43
|
+
constructor(field = 'payload.content') {
|
|
44
|
+
super(field, 'content has no visible content: send at least one non-text block or non-whitespace text');
|
|
45
|
+
this.name = 'EmptyContentError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
39
49
|
/** Where an unread occurrence currently lives. */
|
|
40
50
|
export interface CoalescingPlacement {
|
|
41
51
|
agent: string;
|
|
@@ -100,6 +110,18 @@ interface Rendering<E> {
|
|
|
100
110
|
batch: DeferredBatch<E>;
|
|
101
111
|
cancelled: boolean;
|
|
102
112
|
done: Promise<void>;
|
|
113
|
+
/** Set once the render (or its fallback) was delivered as content. */
|
|
114
|
+
materialized?: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What an assembly did, per subject, for the turn being assembled. */
|
|
118
|
+
export interface AssemblyResult {
|
|
119
|
+
/** Subjects whose render this assembly waited on to completion. */
|
|
120
|
+
settled: Set<string>;
|
|
121
|
+
/** Of those, the subjects whose render or fallback was delivered as content.
|
|
122
|
+
* A settled subject that is not here gave the turn nothing to read: empty
|
|
123
|
+
* or blank render (§5.2), cancelled, or revoked at response. */
|
|
124
|
+
materialized: Set<string>;
|
|
103
125
|
}
|
|
104
126
|
|
|
105
127
|
interface SubjectState<E> {
|
|
@@ -207,8 +229,20 @@ function mergeIdentity(prior: CoalescedOccurrence['identity'], next: CoalescedOc
|
|
|
207
229
|
return out;
|
|
208
230
|
}
|
|
209
231
|
|
|
210
|
-
/**
|
|
211
|
-
|
|
232
|
+
/**
|
|
233
|
+
* Validate wire content before it is stored, rendered or converted.
|
|
234
|
+
*
|
|
235
|
+
* Visibly-empty content is refused unless `allowEmpty`: an admitted
|
|
236
|
+
* occurrence with nothing to show would wake the model with no visible cause.
|
|
237
|
+
* Empty is legitimate only where the RFC gives it a meaning — a retraction
|
|
238
|
+
* with nothing to announce (§6) and a render result that says nothing
|
|
239
|
+
* happened (§5.2) — and for the silent-heartbeat marker.
|
|
240
|
+
*/
|
|
241
|
+
export function validateCoalescedContent(
|
|
242
|
+
content: unknown,
|
|
243
|
+
maxBytes = 1024 * 1024,
|
|
244
|
+
options: { allowEmpty?: boolean } = {},
|
|
245
|
+
): asserts content is McplContentBlock[] {
|
|
212
246
|
if (!Array.isArray(content)) throw new CoalesceError('payload.content', 'content must be an array');
|
|
213
247
|
for (const b of content as Array<Record<string, unknown>>) {
|
|
214
248
|
if (!b || typeof b !== 'object' || Array.isArray(b)) throw new CoalesceError('payload.content', 'invalid content block');
|
|
@@ -219,6 +253,7 @@ export function validateCoalescedContent(content: unknown, maxBytes = 1024 * 102
|
|
|
219
253
|
throw new CoalesceError('payload.content', 'invalid content block');
|
|
220
254
|
}
|
|
221
255
|
if (Buffer.byteLength(JSON.stringify(content)) > maxBytes) throw new CoalesceError('payload.content', 'content exceeds host byte limit');
|
|
256
|
+
if (!options.allowEmpty && isVisiblyEmptyContent(content)) throw new EmptyContentError();
|
|
222
257
|
}
|
|
223
258
|
|
|
224
259
|
/** Validate the `coalesce` member of either lane (§13). */
|
|
@@ -486,7 +521,9 @@ export class PushCoalescer<E = unknown> {
|
|
|
486
521
|
this.host.cancelWake(subject);
|
|
487
522
|
state.consumedEventId = undefined;
|
|
488
523
|
if (state.history === 'none') return 'retracted';
|
|
489
|
-
|
|
524
|
+
// An empty notice — or one with only blank text, which would reach the
|
|
525
|
+
// model as an empty message — is pure withdrawal: append nothing.
|
|
526
|
+
if (isVisiblyEmptyContent(occurrence.content)) return 'consumed';
|
|
490
527
|
// The notice is an ordinary occurrence: it rides the normal delivery path
|
|
491
528
|
// (tags → gate policy → wake) and is consumed like any message — in the
|
|
492
529
|
// context that read a version, never a new one (§3.2).
|
|
@@ -563,20 +600,29 @@ export class PushCoalescer<E = unknown> {
|
|
|
563
600
|
* Render and materialize every pending batch whose audience includes
|
|
564
601
|
* `agentName`. Called by the host at a turn's assembly boundary, before the
|
|
565
602
|
* compile. Bounded by the host's render timeout; a failed or late render
|
|
566
|
-
* materializes the admitted fallback (§5.3).
|
|
603
|
+
* materializes the admitted fallback (§5.3). Reports which subjects
|
|
604
|
+
* settled and which of them produced content, so the host can tell a turn
|
|
605
|
+
* whose only cause rendered nothing.
|
|
567
606
|
*/
|
|
568
|
-
async assemble(agentName: string): Promise<
|
|
569
|
-
|
|
607
|
+
async assemble(agentName: string): Promise<AssemblyResult> {
|
|
608
|
+
const result: AssemblyResult = { settled: new Set(), materialized: new Set() };
|
|
609
|
+
if (this.suspended) return result;
|
|
570
610
|
const work: Promise<void>[] = [];
|
|
571
611
|
for (const subject of [...this.subjects.keys()]) {
|
|
572
612
|
// The handle is wrapped: a bare promise returned through `locked` would
|
|
573
613
|
// be flattened by `then`, holding the lock until the render settled —
|
|
574
614
|
// and the settlement itself needs the lock.
|
|
575
615
|
const started = await this.locked(() => this.freeze(subject, agentName));
|
|
576
|
-
if (started)
|
|
616
|
+
if (started) {
|
|
617
|
+
work.push(started.done.then(() => {
|
|
618
|
+
result.settled.add(subject);
|
|
619
|
+
if (started.rendering.materialized) result.materialized.add(subject);
|
|
620
|
+
}));
|
|
621
|
+
}
|
|
577
622
|
}
|
|
578
623
|
await Promise.all(work);
|
|
579
624
|
this.persist();
|
|
625
|
+
return result;
|
|
580
626
|
}
|
|
581
627
|
|
|
582
628
|
/**
|
|
@@ -585,13 +631,13 @@ export class PushCoalescer<E = unknown> {
|
|
|
585
631
|
* the RPC. Returns the render's completion (shared by every assembly that
|
|
586
632
|
* waits on this batch, vector 25) or undefined when nothing was started.
|
|
587
633
|
*/
|
|
588
|
-
private async freeze(subject: string, agentName: string): Promise<{ done: Promise<void> } | undefined> {
|
|
634
|
+
private async freeze(subject: string, agentName: string): Promise<{ done: Promise<void>; rendering: Rendering<E> } | undefined> {
|
|
589
635
|
const state = this.subjects.get(subject);
|
|
590
636
|
if (!state || this.suspended) return undefined;
|
|
591
637
|
if (state.rendering) {
|
|
592
638
|
// Another assembly froze this batch; share its outcome (vector 25).
|
|
593
639
|
const rendering = state.rendering;
|
|
594
|
-
return (await this.host.audience(rendering.batch.latest)).includes(agentName) && state.rendering === rendering ? { done: rendering.done } : undefined;
|
|
640
|
+
return (await this.host.audience(rendering.batch.latest)).includes(agentName) && state.rendering === rendering ? { done: rendering.done, rendering } : undefined;
|
|
595
641
|
}
|
|
596
642
|
const batch = state.batch;
|
|
597
643
|
if (!batch) return undefined;
|
|
@@ -634,7 +680,7 @@ export class PushCoalescer<E = unknown> {
|
|
|
634
680
|
return undefined;
|
|
635
681
|
}
|
|
636
682
|
rendering.done = this.render(subject, state, rendering, agentName);
|
|
637
|
-
return { done: rendering.done };
|
|
683
|
+
return { done: rendering.done, rendering };
|
|
638
684
|
}
|
|
639
685
|
|
|
640
686
|
private async render(subject: string, state: SubjectState<E>, rendering: Rendering<E>, assemblingFor: string): Promise<void> {
|
|
@@ -654,7 +700,8 @@ export class PushCoalescer<E = unknown> {
|
|
|
654
700
|
try {
|
|
655
701
|
const result = await this.host.render(occurrence, params);
|
|
656
702
|
if (rendering.cancelled) { this.host.audit({ kind: 'late-render', subject, eventId: occurrence.eventId, discarded: true }); }
|
|
657
|
-
|
|
703
|
+
// §5.2: an empty result is legitimate ("nothing happened").
|
|
704
|
+
else validateCoalescedContent(result?.content, this.options.maxContentBytes, { allowEmpty: true });
|
|
658
705
|
if (rendering.cancelled) throw new CancelledRender();
|
|
659
706
|
content = result.content;
|
|
660
707
|
timestamp = typeof result.timestamp === 'string' ? result.timestamp : new Date().toISOString();
|
|
@@ -673,9 +720,11 @@ export class PushCoalescer<E = unknown> {
|
|
|
673
720
|
if (rendering.cancelled || state.rendering !== rendering || this.suspended) return;
|
|
674
721
|
// Rule 4: authority is re-checked at response.
|
|
675
722
|
if (!this.host.authorized(occurrence)) { this.host.audit({ kind: 'revoked', subject, eventId: occurrence.eventId }); return; }
|
|
676
|
-
|
|
677
|
-
|
|
723
|
+
const empty = isVisiblyEmptyContent(content);
|
|
724
|
+
this.host.audit({ kind: 'rendered', subject, eventId: occurrence.eventId, source, empty });
|
|
725
|
+
if (empty) return; // §5.2: nothing happened (blank text included)
|
|
678
726
|
const placement = await this.host.deliver({ ...occurrence, timestamp, content }, content, assemblingFor);
|
|
727
|
+
rendering.materialized = true;
|
|
679
728
|
if (placement?.deferredId) {
|
|
680
729
|
// Landed in another agent's deferred queue (its turn is alive): still
|
|
681
730
|
// unread there, so it stays replaceable and withdrawable.
|
package/src/mcpl/push-handler.ts
CHANGED
|
@@ -18,7 +18,8 @@ import type {
|
|
|
18
18
|
import type { FeatureSetManager } from './feature-set-manager.js';
|
|
19
19
|
import { McplFeatureSetError } from './feature-set-manager.js';
|
|
20
20
|
import { expandCoreTags } from './tags.js';
|
|
21
|
-
import { validateCoalescedContent } from './push-coalescer.js';
|
|
21
|
+
import { EmptyContentError, validateCoalescedContent } from './push-coalescer.js';
|
|
22
|
+
import { isSilentHeartbeatMarker, isVisiblyEmptyContent } from './visible-content.js';
|
|
22
23
|
|
|
23
24
|
// ============================================================================
|
|
24
25
|
// McplPushEvent (the ProcessEvent shape pushed to the queue)
|
|
@@ -188,9 +189,9 @@ export class PushHandler {
|
|
|
188
189
|
* Handle a push/event message from an MCPL server.
|
|
189
190
|
*
|
|
190
191
|
* 1. Validate feature set
|
|
191
|
-
* 2.
|
|
192
|
-
* 3.
|
|
193
|
-
* 4.
|
|
192
|
+
* 2. Convert content blocks; reject visibly-empty content
|
|
193
|
+
* 3. Deduplicate by eventId
|
|
194
|
+
* 4. Optionally check shouldTriggerInference callback
|
|
194
195
|
* 5. Push event to queue
|
|
195
196
|
* 6. Emit trace
|
|
196
197
|
* 7. Respond with accepted + inferenceId
|
|
@@ -229,30 +230,46 @@ export class PushHandler {
|
|
|
229
230
|
return;
|
|
230
231
|
}
|
|
231
232
|
|
|
232
|
-
// 2. Deduplicate by eventId. A coalesced occurrence is deduplicated by the
|
|
233
|
-
// coalescer's receipts instead (RFC-006 §3.1: a retry within the window
|
|
234
|
-
// gets its original result, which this set could not return).
|
|
235
233
|
const coalesced = params.coalesce !== undefined && !!this.handleCoalesced;
|
|
234
|
+
// Empty content is meaningful only for a coalescing retraction (pure
|
|
235
|
+
// withdrawal, RFC-006 §6 — the coalescer appends nothing for it) and for
|
|
236
|
+
// the silent-heartbeat marker. Anything else that shows the model nothing
|
|
237
|
+
// would wake it with no visible cause.
|
|
238
|
+
const emptyAllowed = (coalesced && params.coalesce?.retract === true)
|
|
239
|
+
|| isSilentHeartbeatMarker({ serverId, featureSet: params.featureSet, origin: params.origin, content: params.payload?.content });
|
|
236
240
|
if (coalesced) {
|
|
237
241
|
// RFC-006 §13: malformed content is a -32602, checked before conversion.
|
|
238
242
|
try {
|
|
239
|
-
validateCoalescedContent(params.payload?.content);
|
|
243
|
+
validateCoalescedContent(params.payload?.content, undefined, { allowEmpty: emptyAllowed });
|
|
240
244
|
} catch (error) {
|
|
241
245
|
const err = error as Error & { code?: number; field?: string };
|
|
246
|
+
if (err instanceof EmptyContentError) this.traceEmptyRejection(serverId, params);
|
|
242
247
|
if (responder?.respondError) responder.respondError(err.code ?? -32602, err.message, { field: err.field });
|
|
243
|
-
else responder?.respond({ accepted: false, reason: err.message });
|
|
248
|
+
else responder?.respond({ accepted: false, reason: err instanceof EmptyContentError ? err.reason : err.message });
|
|
244
249
|
return;
|
|
245
250
|
}
|
|
246
251
|
}
|
|
252
|
+
|
|
253
|
+
// 2. Convert content blocks, and refuse content with nothing visible in
|
|
254
|
+
// it — before dedup, so a refused eventId is not burned for a retry.
|
|
255
|
+
const content: ContentBlock[] = params.payload.content.map(convertBlock);
|
|
256
|
+
if (!emptyAllowed && isVisiblyEmptyContent(content)) {
|
|
257
|
+
const err = new EmptyContentError();
|
|
258
|
+
this.traceEmptyRejection(serverId, params);
|
|
259
|
+
if (responder?.respondError) responder.respondError(err.code, err.message, { field: err.field });
|
|
260
|
+
else responder?.respond({ accepted: false, reason: err.reason });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 3. Deduplicate by eventId. A coalesced occurrence is deduplicated by the
|
|
265
|
+
// coalescer's receipts instead (RFC-006 §3.1: a retry within the window
|
|
266
|
+
// gets its original result, which this set could not return).
|
|
247
267
|
if (!coalesced && this.dedup.checkAndAdd(params.eventId)) {
|
|
248
268
|
console.error(`[push-event-rejected] server=${serverId} eventId=${params.eventId} reason=duplicate`);
|
|
249
269
|
responder?.respond({ accepted: false, reason: 'duplicate' });
|
|
250
270
|
return;
|
|
251
271
|
}
|
|
252
272
|
|
|
253
|
-
// 3. Convert content blocks
|
|
254
|
-
const content: ContentBlock[] = params.payload.content.map(convertBlock);
|
|
255
|
-
|
|
256
273
|
// 4. Check shouldTriggerInference callback
|
|
257
274
|
let triggerInference = true;
|
|
258
275
|
if (this.shouldTriggerInference) {
|
|
@@ -316,4 +333,16 @@ export class PushHandler {
|
|
|
316
333
|
// 8. Respond
|
|
317
334
|
responder?.respond({ accepted: true, inferenceId });
|
|
318
335
|
}
|
|
336
|
+
|
|
337
|
+
/** Loud, like every other push rejection: the producer's wake went nowhere. */
|
|
338
|
+
private traceEmptyRejection(serverId: string, params: PushEventParams): void {
|
|
339
|
+
console.error(`[push-event-rejected] server=${serverId} eventId=${params.eventId} reason=empty-content`);
|
|
340
|
+
this.emitTraceFn({
|
|
341
|
+
type: 'mcpl:push-event-rejected',
|
|
342
|
+
serverId,
|
|
343
|
+
eventId: params.eventId,
|
|
344
|
+
featureSet: params.featureSet,
|
|
345
|
+
reason: 'empty-content',
|
|
346
|
+
});
|
|
347
|
+
}
|
|
319
348
|
}
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { createHash } from 'node:crypto';
|
|
31
31
|
import type { ReferenceRecord } from './references.js';
|
|
32
|
-
import { isReferenceExpired } from './references.js';
|
|
32
|
+
import { EXPIRY_REASON, isReferenceExpired, referenceExpiry, REFERENCE_LIMITS, sanitizeLabel } from './references.js';
|
|
33
33
|
|
|
34
34
|
/** Per-fetch hard ceiling on actual bytes (overridable per server / call). */
|
|
35
35
|
export const DEFAULT_FETCH_MAX_BYTES = 256 * 1024 * 1024;
|
|
@@ -79,13 +79,14 @@ export function sniffMime(data: Buffer): string | null {
|
|
|
79
79
|
return null;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
/** A Map, so a server's type such as `constructor` finds no inherited key. */
|
|
83
|
+
const EXT_BY_MIME = new Map<string, string>(Object.entries({
|
|
83
84
|
'audio/wav': 'wav', 'audio/x-wav': 'wav', 'audio/mpeg': 'mp3', 'audio/mp4': 'm4a',
|
|
84
85
|
'audio/flac': 'flac', 'audio/midi': 'mid', 'audio/ogg': 'ogg',
|
|
85
86
|
'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp',
|
|
86
87
|
'video/mp4': 'mp4', 'application/json': 'json', 'text/plain': 'txt',
|
|
87
88
|
'application/pdf': 'pdf', 'application/zip': 'zip',
|
|
88
|
-
};
|
|
89
|
+
}));
|
|
89
90
|
|
|
90
91
|
/** ws→http / wss→https with default ports elided, for origin comparison. */
|
|
91
92
|
export function normalizedOrigin(url: string): string | null {
|
|
@@ -111,9 +112,11 @@ export class ReferenceFetcher {
|
|
|
111
112
|
private readonly saveFile: (fileName: string, data: Buffer, mimeType: string) => Promise<string | null>,
|
|
112
113
|
) {}
|
|
113
114
|
|
|
114
|
-
/** Would the eager (autofetch) default fetch this record?
|
|
115
|
+
/** Would the eager (autofetch) default fetch this record? Not one the
|
|
116
|
+
* fetcher refuses as expired, which would only take an eager slot. */
|
|
115
117
|
isEagerEligible(record: ReferenceRecord): boolean {
|
|
116
118
|
if (record.fetchedPath) return false;
|
|
119
|
+
if (isReferenceExpired(record.testimony)) return false;
|
|
117
120
|
if (record.serverId === undefined) return false;
|
|
118
121
|
const binding = this.resolveServer(record.serverId);
|
|
119
122
|
if (!binding) return false;
|
|
@@ -161,7 +164,8 @@ export class ReferenceFetcher {
|
|
|
161
164
|
|
|
162
165
|
const refusal = this.originCheck(record, binding);
|
|
163
166
|
if (refusal) return { ok: false, error: refusal };
|
|
164
|
-
|
|
167
|
+
const expiry = referenceExpiry(t);
|
|
168
|
+
if (expiry) return { ok: false, error: `reference ${EXPIRY_REASON[expiry]}` };
|
|
165
169
|
|
|
166
170
|
const ceiling = opts?.maxBytes ?? DEFAULT_FETCH_MAX_BYTES;
|
|
167
171
|
// Vector 4: a claim already over the ceiling refuses without dialing.
|
|
@@ -215,9 +219,15 @@ export class ReferenceFetcher {
|
|
|
215
219
|
digestVerified = true;
|
|
216
220
|
}
|
|
217
221
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
222
|
+
// The response's header and the server's claimed type are both the
|
|
223
|
+
// server's words, and either can become fetch_reference's mime_type,
|
|
224
|
+
// the saved type and the record's verified type, so each is shown as a
|
|
225
|
+
// label is. One left empty once sanitized counts as absent.
|
|
226
|
+
const header = resp.headers.get('content-type');
|
|
227
|
+
const headerMime = header !== null ? sanitizeLabel(header.split(';')[0], REFERENCE_LIMITS.mimeType) || null : null;
|
|
228
|
+
const claimedMime = t.mimeType !== undefined ? sanitizeLabel(t.mimeType, REFERENCE_LIMITS.mimeType) || undefined : undefined;
|
|
229
|
+
const observedMime = sniffMime(data) ?? headerMime ?? claimedMime ?? 'application/octet-stream';
|
|
230
|
+
const ext = EXT_BY_MIME.get(observedMime) ?? EXT_BY_MIME.get(headerMime ?? '') ?? EXT_BY_MIME.get(claimedMime ?? '') ?? 'bin';
|
|
221
231
|
// §7.3: host-generated storage name; the server's `name` is display-only.
|
|
222
232
|
const path = await this.saveFile(`${record.refId}.${ext}`, data, observedMime);
|
|
223
233
|
if (path === null) return { ok: false, error: 'no writable workspace mount to store the payload' };
|
package/src/mcpl/references.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* - `disposition:"never"` withholds payload AND uri from model context
|
|
14
14
|
* unconditionally — enforced in code here, not by caller discipline (§5);
|
|
15
15
|
* - stub size is independent of every server-supplied field length (§5);
|
|
16
|
-
* - unparseable `expiresAt` fails closed (§7.4)
|
|
16
|
+
* - unparseable `expiresAt` fails closed (§7.4), and so does one rejected
|
|
17
|
+
* as a field: it's absent as testimony, never read as immortal;
|
|
17
18
|
* - unknown block types never propagate `undefined` (the pre-existing
|
|
18
19
|
* convertBlock switches had no default — a latent corruption this module
|
|
19
20
|
* retires).
|
|
@@ -141,16 +142,42 @@ export function isInlineContradiction(block: unknown): boolean {
|
|
|
141
142
|
export const INLINE_WITHHELD_TEXT =
|
|
142
143
|
'[inline content withheld: nonconformant bulk disposition on inline data]';
|
|
143
144
|
|
|
144
|
-
|
|
145
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Why a reference counts as expired, or null when it doesn't: its expiresAt
|
|
147
|
+
* has passed, doesn't parse, or was rejected as a field (§7.4, §8). A
|
|
148
|
+
* rejected expiry (past its schema maximum, or not a string, null included)
|
|
149
|
+
* is absent as testimony under the one invalid-field rule, but reading that
|
|
150
|
+
* absence as "never expires" would let field invalidity widen exposure:
|
|
151
|
+
* like an unparseable one, it fails closed. A reference that claims no
|
|
152
|
+
* expiry has none.
|
|
153
|
+
*/
|
|
154
|
+
export function referenceExpiry(
|
|
155
|
+
t: Pick<ReferenceTestimony, 'expiresAt' | 'rejectedFields'>, nowMs = Date.now(),
|
|
156
|
+
): 'rejected' | 'unparseable' | 'passed' | null {
|
|
157
|
+
if (t.rejectedFields.includes('expiresAt')) return 'rejected';
|
|
158
|
+
if (t.expiresAt === undefined) return null;
|
|
146
159
|
const exp = Date.parse(t.expiresAt);
|
|
147
|
-
|
|
160
|
+
if (Number.isNaN(exp)) return 'unparseable';
|
|
161
|
+
return exp <= nowMs ? 'passed' : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function isReferenceExpired(t: Pick<ReferenceTestimony, 'expiresAt' | 'rejectedFields'>, nowMs = Date.now()): boolean {
|
|
165
|
+
return referenceExpiry(t, nowMs) !== null;
|
|
148
166
|
}
|
|
149
167
|
|
|
150
|
-
/**
|
|
168
|
+
/** Each expiry case as the stub and the fetcher's refusal both say it. */
|
|
169
|
+
export const EXPIRY_REASON = {
|
|
170
|
+
passed: 'expired: its expiresAt has passed',
|
|
171
|
+
unparseable: 'treated as expired: its expiresAt does not parse (fail closed)',
|
|
172
|
+
rejected: `treated as expired: its expiresAt was rejected as a field, over ${REFERENCE_LIMITS.expiresAt} characters or not a string (fail closed)`,
|
|
173
|
+
} as const;
|
|
174
|
+
|
|
175
|
+
/** Strip control characters (C0, DEL, C1) and every bidi control (marks,
|
|
176
|
+
* embeddings, overrides, isolates, and the Arabic letter mark), trim the
|
|
177
|
+
* whitespace (a BOM included) left at either end, and bound the length.
|
|
151
178
|
* Labels are labels — storage naming is host-generated elsewhere (§7.3). */
|
|
152
179
|
export function sanitizeLabel(s: string, maxChars: number): string {
|
|
153
|
-
const cleaned = s.replace(/[\
|
|
180
|
+
const cleaned = s.replace(/[\p{Cc}\p{Bidi_Control}]/gu, '').trim();
|
|
154
181
|
if (cleaned.length <= maxChars) return cleaned;
|
|
155
182
|
let cut = cleaned.slice(0, Math.max(0, maxChars - 1));
|
|
156
183
|
// never split a surrogate pair at the truncation boundary
|
|
@@ -187,9 +214,10 @@ const MAX_RECORDS = 2000;
|
|
|
187
214
|
/**
|
|
188
215
|
* Host-private store of received references. Raw URIs live here and in stubs
|
|
189
216
|
* never (under `never`) or by policy only (`ref`/absent). Ids are stable and
|
|
190
|
-
* never reused within the process (vector 20)
|
|
191
|
-
*
|
|
192
|
-
*
|
|
217
|
+
* never reused within the process (vector 20). Past MAX_RECORDS the record
|
|
218
|
+
* registered least recently is evicted (registering a uri the registry holds
|
|
219
|
+
* makes its record the newest), and a stale lookup returns undefined — a
|
|
220
|
+
* defined miss, never a different record.
|
|
193
221
|
*
|
|
194
222
|
* Process-wide singleton by design: ids are host-local *names*, not
|
|
195
223
|
* capabilities, and the record itself carries serverId for any future
|
|
@@ -222,6 +250,9 @@ export class ReferenceRegistry {
|
|
|
222
250
|
// describes verified bytes on disk and keeps the testimony they were
|
|
223
251
|
// verified against.
|
|
224
252
|
if (!existing.fetchedPath) existing.testimony = testimony;
|
|
253
|
+
// Newest again, so a uri just mentioned is the last to be evicted.
|
|
254
|
+
this.records.delete(existingId);
|
|
255
|
+
this.records.set(existingId, existing);
|
|
225
256
|
return existing;
|
|
226
257
|
}
|
|
227
258
|
}
|
|
@@ -257,6 +288,12 @@ export const referenceRegistry = new ReferenceRegistry();
|
|
|
257
288
|
// Stub building (§5)
|
|
258
289
|
// ============================================================================
|
|
259
290
|
|
|
291
|
+
/** A reference's type as its stub shows one, or '' when nothing is left of
|
|
292
|
+
* it once sanitized (the stub then leaves it out). */
|
|
293
|
+
export function referenceTypeLabel(mimeType: string | undefined): string {
|
|
294
|
+
return mimeType ? sanitizeLabel(mimeType, STUB_FIELD_CHARS) : '';
|
|
295
|
+
}
|
|
296
|
+
|
|
260
297
|
/**
|
|
261
298
|
* Build the model-visible stub for a reference. Output length is bounded by
|
|
262
299
|
* host constants and independent of every server-supplied field length.
|
|
@@ -267,18 +304,25 @@ export const referenceRegistry = new ReferenceRegistry();
|
|
|
267
304
|
export function buildReferenceStub(t: ReferenceTestimony, provenance?: string, serverId?: string): string {
|
|
268
305
|
const record = referenceRegistry.register(t, serverId);
|
|
269
306
|
const parts: string[] = [];
|
|
270
|
-
|
|
307
|
+
// A label is shown only when something is left of it once sanitized.
|
|
308
|
+
const name = t.name ? sanitizeLabel(t.name, STUB_FIELD_CHARS) : '';
|
|
309
|
+
if (name) parts.push(name);
|
|
271
310
|
const meta: string[] = [];
|
|
272
|
-
|
|
311
|
+
const mimeType = referenceTypeLabel(t.mimeType);
|
|
312
|
+
if (mimeType) meta.push(mimeType);
|
|
273
313
|
if (t.sizeBytes !== undefined) meta.push(`${formatSizeBytes(t.sizeBytes)} claimed`);
|
|
274
314
|
if (meta.length) parts.push(meta.join(', '));
|
|
275
|
-
|
|
315
|
+
const origin = provenance ? sanitizeLabel(provenance, STUB_FIELD_CHARS) : '';
|
|
316
|
+
if (origin) parts.push(origin);
|
|
276
317
|
if (record.fetchedPath) {
|
|
277
318
|
// Already materialized by the host-mediated fetcher: hand the model the
|
|
278
319
|
// workspace path (host-generated, §7.3) so existing tools just work.
|
|
279
320
|
parts.push(`saved: ${sanitizeLabel(record.fetchedPath, 256)}`);
|
|
280
321
|
} else {
|
|
281
|
-
|
|
322
|
+
// An expired reference says so rather than inviting a fetch_reference
|
|
323
|
+
// call the fetcher can only refuse.
|
|
324
|
+
const expiry = referenceExpiry(record.testimony);
|
|
325
|
+
parts.push(expiry ? EXPIRY_REASON[expiry] : 'fetch with fetch_reference');
|
|
282
326
|
}
|
|
283
327
|
return `[${record.refId}] ${parts.join(' — ') || 'referenced content'}`;
|
|
284
328
|
}
|
|
@@ -48,6 +48,28 @@ import type {
|
|
|
48
48
|
import { McplMethod } from './types.js';
|
|
49
49
|
import type { ToolLifecycleParams, ToolObserveRule } from './tool-lifecycle.js';
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Payload of the connection `'close'` event (third argument; the first two
|
|
53
|
+
* stay `(code, signal)` for existing listeners). Lets a host see WHY the
|
|
54
|
+
* link went down — in particular a WebSocket close code such as 4001 from
|
|
55
|
+
* a server that refused or expired the credential — so it can refresh what
|
|
56
|
+
* `accessProvider` will hand the next dial instead of replaying it.
|
|
57
|
+
*/
|
|
58
|
+
export interface McplConnectionCloseInfo {
|
|
59
|
+
/** Transport kind that closed; null when the connection never had one
|
|
60
|
+
* (a disconnected stub torn down before its first reconnect). */
|
|
61
|
+
transport: McplTransport['kind'] | null;
|
|
62
|
+
/** WebSocket close code (e.g. 4001) or stdio child exit code; null when unknown. */
|
|
63
|
+
code: number | null;
|
|
64
|
+
/** Signal that ended a stdio child; null otherwise. */
|
|
65
|
+
signal: NodeJS.Signals | null;
|
|
66
|
+
/** WebSocket close reason, or a short description of the cause. */
|
|
67
|
+
reason?: string;
|
|
68
|
+
/** True when the host closed the link (`close()` / `reconnectAfterFailure()`),
|
|
69
|
+
* false when the transport dropped unexpectedly. */
|
|
70
|
+
byHost: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
51
73
|
/** Timeout for the initialize handshake in milliseconds.
|
|
52
74
|
* Spring Boot + JDA servers can take 5-10s to boot, so 30s is safe. */
|
|
53
75
|
const INITIALIZE_TIMEOUT_MS = 30_000;
|
|
@@ -93,7 +115,7 @@ interface RequestOptions {
|
|
|
93
115
|
* - `'channels-changed'` — Server sent `channels/changed`
|
|
94
116
|
* - `'channels-incoming'` — Server sent `channels/incoming`
|
|
95
117
|
* - `'error'` — Connection-level error
|
|
96
|
-
* - `'close'` — Connection closed
|
|
118
|
+
* - `'close'` — Connection closed: `(code, signal, info: McplConnectionCloseInfo)`
|
|
97
119
|
* - `'connect-failed'` — Initial connect failed; background retry scheduled
|
|
98
120
|
* - `'reconnect-failed'` — A background reconnect attempt failed; next retry scheduled
|
|
99
121
|
* - `'reconnect'` — Background reconnect succeeded
|
|
@@ -209,6 +231,12 @@ export class McplServerConnection extends EventEmitter {
|
|
|
209
231
|
* observable even though its original promise has already been rejected. */
|
|
210
232
|
private orphanedRequests = new Map<string | number, { method: string; renderParams?: Record<string, unknown> }>();
|
|
211
233
|
private closed = false;
|
|
234
|
+
/** Set by `reconnectAfterFailure()` before it closes a live transport, so
|
|
235
|
+
* the transport close handler reports that teardown as `byHost: true`. */
|
|
236
|
+
private hostCloseRequested = false;
|
|
237
|
+
private closePromise: Promise<void> | null = null;
|
|
238
|
+
private closeIncomplete = false;
|
|
239
|
+
private reconnectPromise: Promise<void> | null = null;
|
|
212
240
|
|
|
213
241
|
/** Per-request timeout in ms (0 disables). See McplServerConfig.requestTimeoutMs. */
|
|
214
242
|
private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
|
|
@@ -837,10 +865,10 @@ export class McplServerConnection extends EventEmitter {
|
|
|
837
865
|
this.reconnectTimer = null;
|
|
838
866
|
}
|
|
839
867
|
|
|
840
|
-
if (this.
|
|
841
|
-
|
|
842
|
-
}
|
|
868
|
+
if (this.closePromise) return this.closePromise;
|
|
869
|
+
if (this.closed && !this.closeIncomplete && !this.reconnectPromise) return;
|
|
843
870
|
this.closed = true;
|
|
871
|
+
this.closeIncomplete = true;
|
|
844
872
|
this.resetPolicyForTransportBoundary();
|
|
845
873
|
|
|
846
874
|
// Reject all pending requests
|
|
@@ -854,11 +882,20 @@ export class McplServerConnection extends EventEmitter {
|
|
|
854
882
|
// Tear down the transport (kills the child / closes the socket). The
|
|
855
883
|
// transport's own 'close' event is short-circuited by `this.closed` in
|
|
856
884
|
// setupLifecycle, so this is the single source of the connection 'close'.
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
885
|
+
const attempt = Promise.resolve().then(async () => {
|
|
886
|
+
// An already opened handshake owns a transport too. It hands that
|
|
887
|
+
// transport back without admission once reconnect has been disabled.
|
|
888
|
+
// The registry bounds this wait; retry reuses this real close attempt.
|
|
889
|
+
if (this.reconnectPromise) await this.reconnectPromise;
|
|
890
|
+
if (this.transport) await this.transport.close();
|
|
891
|
+
this.closeIncomplete = false;
|
|
892
|
+
this.emit('close', null, null, this.closeInfoByHost());
|
|
893
|
+
});
|
|
894
|
+
this.closePromise = attempt;
|
|
895
|
+
void attempt.catch(() => {
|
|
896
|
+
if (this.closePromise === attempt) this.closePromise = null;
|
|
897
|
+
});
|
|
898
|
+
return attempt;
|
|
862
899
|
}
|
|
863
900
|
|
|
864
901
|
/**
|
|
@@ -874,14 +911,26 @@ export class McplServerConnection extends EventEmitter {
|
|
|
874
911
|
return;
|
|
875
912
|
}
|
|
876
913
|
if (this.transport) {
|
|
914
|
+
this.hostCloseRequested = true;
|
|
877
915
|
await this.transport.close();
|
|
878
916
|
return;
|
|
879
917
|
}
|
|
880
918
|
this.closed = true;
|
|
881
|
-
this.emit('close');
|
|
919
|
+
this.emit('close', null, null, this.closeInfoByHost());
|
|
882
920
|
this.scheduleReconnect();
|
|
883
921
|
}
|
|
884
922
|
|
|
923
|
+
/** 'close' payload for a host-initiated teardown (no transport close code). */
|
|
924
|
+
private closeInfoByHost(): McplConnectionCloseInfo {
|
|
925
|
+
return {
|
|
926
|
+
transport: this.transport?.kind ?? null,
|
|
927
|
+
code: null,
|
|
928
|
+
signal: null,
|
|
929
|
+
reason: 'closed by host',
|
|
930
|
+
byHost: true,
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
885
934
|
/**
|
|
886
935
|
* Schedule a background reconnection attempt.
|
|
887
936
|
*
|
|
@@ -912,8 +961,19 @@ export class McplServerConnection extends EventEmitter {
|
|
|
912
961
|
* approach this builds no throwaway connection instance, so there are no
|
|
913
962
|
* leaked listeners on a dead object.
|
|
914
963
|
*/
|
|
915
|
-
private
|
|
916
|
-
if (
|
|
964
|
+
private attemptReconnect(): Promise<void> {
|
|
965
|
+
if (this.reconnectPromise) return this.reconnectPromise;
|
|
966
|
+
const attempt = this.reconnectOnce();
|
|
967
|
+
this.reconnectPromise = attempt;
|
|
968
|
+
const clear = () => { if (this.reconnectPromise === attempt) this.reconnectPromise = null; };
|
|
969
|
+
void attempt.then(clear, clear);
|
|
970
|
+
return attempt;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
private async reconnectOnce(): Promise<void> {
|
|
974
|
+
// A queued recovery timer may outlive an earlier successful handshake.
|
|
975
|
+
// Recheck disconnection before opening or replacing any owned transport.
|
|
976
|
+
if (!this.closed || !this.reconnectEnabled || !this.config || !this.hostCapabilities) return;
|
|
917
977
|
|
|
918
978
|
// Ordinal of this attempt: 0 was the initial connect, so the Nth retry
|
|
919
979
|
// reports attempt N (reconnectAttempts counts failures so far).
|
|
@@ -925,6 +985,14 @@ export class McplServerConnection extends EventEmitter {
|
|
|
925
985
|
this.hostCapabilities,
|
|
926
986
|
);
|
|
927
987
|
|
|
988
|
+
if (!this.reconnectEnabled) {
|
|
989
|
+
// The prior transport was disconnected before this retry began. Keep
|
|
990
|
+
// the fresh, unadmitted transport owned by close(), including on a
|
|
991
|
+
// failed transport cleanup, instead of reviving a stopped connection.
|
|
992
|
+
this.transport = transport;
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
|
|
928
996
|
// Close the data plane before the new transport can emit. The reconnect
|
|
929
997
|
// lifecycle event bypasses buffering; its framework listener installs the
|
|
930
998
|
// awareness barrier synchronously, then releases control traffic needed
|
|
@@ -1254,6 +1322,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1254
1322
|
* and — when enabled — schedules a background reconnect.
|
|
1255
1323
|
*/
|
|
1256
1324
|
private setupLifecycle(transport: McplTransport): void {
|
|
1325
|
+
this.hostCloseRequested = false;
|
|
1257
1326
|
transport.on('error', (err: Error) => {
|
|
1258
1327
|
this.emit('error', new Error(`MCPL server "${this.id}" transport error: ${err.message}`));
|
|
1259
1328
|
});
|
|
@@ -1275,7 +1344,20 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1275
1344
|
this.pendingRequests.clear();
|
|
1276
1345
|
this.orphanedRequests.clear();
|
|
1277
1346
|
|
|
1278
|
-
|
|
1347
|
+
// Forward the transport's close info so listeners can tell a server
|
|
1348
|
+
// that refused the credential (WebSocket 4001) from a crash, and act
|
|
1349
|
+
// before the reconnect below redials. A teardown the host asked for
|
|
1350
|
+
// via reconnectAfterFailure() arrives here too; report it as such.
|
|
1351
|
+
const byHost = this.hostCloseRequested;
|
|
1352
|
+
this.hostCloseRequested = false;
|
|
1353
|
+
const closeInfo: McplConnectionCloseInfo = {
|
|
1354
|
+
transport: transport.kind,
|
|
1355
|
+
code: info.code ?? null,
|
|
1356
|
+
signal: info.signal ?? null,
|
|
1357
|
+
reason: info.reason ?? (byHost ? 'closed by host' : undefined),
|
|
1358
|
+
byHost,
|
|
1359
|
+
};
|
|
1360
|
+
this.emit('close', closeInfo.code, closeInfo.signal, closeInfo);
|
|
1279
1361
|
|
|
1280
1362
|
// Schedule reconnect if enabled (unexpected disconnect triggers auto-reconnect)
|
|
1281
1363
|
if (this.reconnectEnabled) {
|