@animalabs/agent-framework 0.23.2 → 0.24.0
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 +212 -42
- package/dist/src/agent.d.ts +60 -9
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +92 -12
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +14 -12
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/code-execution/engine.d.ts +177 -0
- package/dist/src/code-execution/engine.d.ts.map +1 -0
- package/dist/src/code-execution/engine.js +494 -0
- package/dist/src/code-execution/engine.js.map +1 -0
- package/dist/src/code-execution/index.d.ts +5 -10
- package/dist/src/code-execution/index.d.ts.map +1 -1
- package/dist/src/code-execution/index.js +5 -9
- package/dist/src/code-execution/index.js.map +1 -1
- package/dist/src/code-execution/isolate/calls.d.ts +114 -0
- package/dist/src/code-execution/isolate/calls.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/calls.js +145 -0
- package/dist/src/code-execution/isolate/calls.js.map +1 -0
- package/dist/src/code-execution/isolate/child-entry.d.ts +2 -0
- package/dist/src/code-execution/isolate/child-entry.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/child-entry.js +5 -0
- package/dist/src/code-execution/isolate/child-entry.js.map +1 -0
- package/dist/src/code-execution/isolate/child-main.d.ts +26 -0
- package/dist/src/code-execution/isolate/child-main.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/child-main.js +132 -0
- package/dist/src/code-execution/isolate/child-main.js.map +1 -0
- package/dist/src/code-execution/isolate/child.d.ts +44 -0
- package/dist/src/code-execution/isolate/child.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/child.js +315 -0
- package/dist/src/code-execution/isolate/child.js.map +1 -0
- package/dist/src/code-execution/isolate/frames.d.ts +80 -0
- package/dist/src/code-execution/isolate/frames.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/frames.js +210 -0
- package/dist/src/code-execution/isolate/frames.js.map +1 -0
- package/dist/src/code-execution/isolate/output.d.ts +68 -0
- package/dist/src/code-execution/isolate/output.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/output.js +143 -0
- package/dist/src/code-execution/isolate/output.js.map +1 -0
- package/dist/src/code-execution/isolate/prelude.d.ts +109 -0
- package/dist/src/code-execution/isolate/prelude.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/prelude.js +401 -0
- package/dist/src/code-execution/isolate/prelude.js.map +1 -0
- package/dist/src/code-execution/isolate/protocol.d.ts +151 -0
- package/dist/src/code-execution/isolate/protocol.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/protocol.js +150 -0
- package/dist/src/code-execution/isolate/protocol.js.map +1 -0
- package/dist/src/code-execution/isolate/render.d.ts +57 -0
- package/dist/src/code-execution/isolate/render.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/render.js +214 -0
- package/dist/src/code-execution/isolate/render.js.map +1 -0
- package/dist/src/code-execution/isolate/runner.d.ts +250 -0
- package/dist/src/code-execution/isolate/runner.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/runner.js +854 -0
- package/dist/src/code-execution/isolate/runner.js.map +1 -0
- package/dist/src/code-execution/isolate/scripts.d.ts +175 -0
- package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -0
- package/dist/src/code-execution/isolate/scripts.js +357 -0
- package/dist/src/code-execution/isolate/scripts.js.map +1 -0
- package/dist/src/code-execution/quickjs.wasm +0 -0
- package/dist/src/code-execution/tool-definition.d.ts +93 -43
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +176 -142
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/deadline-timer.d.ts +3 -0
- package/dist/src/deadline-timer.d.ts.map +1 -1
- package/dist/src/deadline-timer.js +1 -1
- package/dist/src/deadline-timer.js.map +1 -1
- package/dist/src/framework.d.ts +1027 -440
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +4093 -2238
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +28 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +88 -10
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/held-writes.d.ts +16 -3
- package/dist/src/held-writes.d.ts.map +1 -1
- package/dist/src/held-writes.js +16 -3
- package/dist/src/held-writes.js.map +1 -1
- package/dist/src/index.d.ts +6 -6
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +9 -8
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +471 -75
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +1673 -337
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +12 -7
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +12 -7
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/convert-block.d.ts.map +1 -1
- package/dist/src/mcpl/convert-block.js +9 -5
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/inbound-source.d.ts +50 -0
- package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
- package/dist/src/mcpl/inbound-source.js +130 -0
- package/dist/src/mcpl/inbound-source.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +2 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -2
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/log-value.d.ts +6 -0
- package/dist/src/mcpl/log-value.d.ts.map +1 -1
- package/dist/src/mcpl/log-value.js +8 -0
- package/dist/src/mcpl/log-value.js.map +1 -1
- package/dist/src/mcpl/modern-connection.d.ts +15 -6
- package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
- package/dist/src/mcpl/modern-connection.js +168 -53
- package/dist/src/mcpl/modern-connection.js.map +1 -1
- package/dist/src/mcpl/prose-grammar.d.ts +4 -5
- package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
- package/dist/src/mcpl/prose-grammar.js +4 -5
- package/dist/src/mcpl/prose-grammar.js.map +1 -1
- package/dist/src/mcpl/protocol-family.d.ts +97 -10
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
- package/dist/src/mcpl/protocol-family.js +173 -16
- package/dist/src/mcpl/protocol-family.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +132 -14
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +227 -50
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +6 -1
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +4 -1
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +2 -2
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +4 -4
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +50 -21
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +158 -74
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tags.d.ts +24 -0
- package/dist/src/mcpl/tags.d.ts.map +1 -1
- package/dist/src/mcpl/tags.js +33 -0
- package/dist/src/mcpl/tags.js.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts +2 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
- package/dist/src/mcpl/transport.d.ts +55 -7
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +163 -34
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +22 -6
- 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 +4 -2
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +100 -13
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +8 -13
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +27 -4
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts +15 -3
- package/dist/src/modules/history/semantic.d.ts.map +1 -1
- package/dist/src/modules/history/semantic.js +27 -5
- package/dist/src/modules/history/semantic.js.map +1 -1
- package/dist/src/modules/index.d.ts +1 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/unknown-tool.d.ts +4 -1
- package/dist/src/modules/unknown-tool.d.ts.map +1 -1
- package/dist/src/modules/unknown-tool.js +4 -1
- package/dist/src/modules/unknown-tool.js.map +1 -1
- package/dist/src/modules/workspace/branch-intent.d.ts +3 -1
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
- package/dist/src/modules/workspace/branch-intent.js +3 -1
- package/dist/src/modules/workspace/branch-intent.js.map +1 -1
- package/dist/src/modules/workspace/edit-plan.d.ts +265 -0
- package/dist/src/modules/workspace/edit-plan.d.ts.map +1 -0
- package/dist/src/modules/workspace/edit-plan.js +794 -0
- package/dist/src/modules/workspace/edit-plan.js.map +1 -0
- package/dist/src/modules/workspace/edit-text.d.ts +117 -0
- package/dist/src/modules/workspace/edit-text.d.ts.map +1 -0
- package/dist/src/modules/workspace/edit-text.js +584 -0
- package/dist/src/modules/workspace/edit-text.js.map +1 -0
- package/dist/src/modules/workspace/effects.d.ts +5 -4
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +78 -25
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +484 -61
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +2173 -645
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +213 -38
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +416 -36
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
- package/dist/src/modules/workspace/reconcile.js +15 -8
- package/dist/src/modules/workspace/reconcile.js.map +1 -1
- package/dist/src/modules/workspace/text.d.ts +32 -0
- package/dist/src/modules/workspace/text.d.ts.map +1 -0
- package/dist/src/modules/workspace/text.js +67 -0
- package/dist/src/modules/workspace/text.js.map +1 -0
- package/dist/src/modules/workspace/types.d.ts +210 -6
- 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 +3 -2
- 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 +52 -24
- package/dist/src/operator-log.d.ts.map +1 -1
- package/dist/src/operator-log.js.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.d.ts +496 -72
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.js +1196 -289
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
- package/dist/src/recovery/offline-branch.d.ts +26 -1
- package/dist/src/recovery/offline-branch.d.ts.map +1 -1
- package/dist/src/recovery/offline-branch.js +164 -39
- package/dist/src/recovery/offline-branch.js.map +1 -1
- package/dist/src/recovery/recover-cli.js +23 -4
- package/dist/src/recovery/recover-cli.js.map +1 -1
- package/dist/src/send-tools.d.ts +11 -0
- package/dist/src/send-tools.d.ts.map +1 -0
- package/dist/src/send-tools.js +11 -0
- package/dist/src/send-tools.js.map +1 -0
- package/dist/src/tool-result-guard.d.ts +4 -3
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +5 -4
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +1 -1
- package/dist/src/tool-result-history.js +1 -1
- package/dist/src/tune-out/coordinator.d.ts +21 -0
- package/dist/src/tune-out/coordinator.d.ts.map +1 -1
- package/dist/src/tune-out/coordinator.js +139 -37
- package/dist/src/tune-out/coordinator.js.map +1 -1
- package/dist/src/types/agent.d.ts +4 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +62 -9
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +62 -58
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +40 -8
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +111 -10
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/src/usage/types.d.ts +16 -0
- package/dist/src/usage/types.d.ts.map +1 -1
- package/dist/src/usage/usage-tracker.d.ts +14 -1
- package/dist/src/usage/usage-tracker.d.ts.map +1 -1
- package/dist/src/usage/usage-tracker.js +81 -41
- package/dist/src/usage/usage-tracker.js.map +1 -1
- package/package.json +6 -5
- package/src/agent.ts +93 -12
- package/src/api/server.ts +14 -12
- package/src/code-execution/engine.ts +595 -0
- package/src/code-execution/index.ts +5 -17
- package/src/code-execution/isolate/calls.ts +199 -0
- package/src/code-execution/isolate/child-entry.ts +5 -0
- package/src/code-execution/isolate/child-main.ts +132 -0
- package/src/code-execution/isolate/child.ts +327 -0
- package/src/code-execution/isolate/frames.ts +209 -0
- package/src/code-execution/isolate/output.ts +161 -0
- package/src/code-execution/isolate/prelude.ts +409 -0
- package/src/code-execution/isolate/protocol.ts +240 -0
- package/src/code-execution/isolate/render.ts +230 -0
- package/src/code-execution/isolate/runner.ts +911 -0
- package/src/code-execution/isolate/scripts.ts +479 -0
- package/src/code-execution/quickjs.wasm +0 -0
- package/src/code-execution/quickjs.wasm.LICENSES +149 -0
- package/src/code-execution/tool-definition.ts +236 -167
- package/src/deadline-timer.ts +1 -1
- package/src/framework.ts +4468 -2418
- package/src/gate/event-gate.ts +78 -11
- package/src/held-writes.ts +17 -3
- package/src/index.ts +30 -22
- package/src/mcpl/channel-registry.ts +1825 -368
- package/src/mcpl/conversation-router.ts +12 -7
- package/src/mcpl/convert-block.ts +9 -5
- package/src/mcpl/inbound-source.ts +131 -0
- package/src/mcpl/index.ts +4 -2
- package/src/mcpl/log-value.ts +9 -0
- package/src/mcpl/modern-connection.ts +162 -53
- package/src/mcpl/prose-grammar.ts +4 -5
- package/src/mcpl/protocol-family.ts +199 -21
- package/src/mcpl/push-coalescer.ts +306 -62
- package/src/mcpl/push-handler.ts +10 -2
- package/src/mcpl/reference-fetcher.ts +4 -4
- package/src/mcpl/server-connection.ts +180 -79
- package/src/mcpl/tags.ts +36 -0
- package/src/mcpl/tool-result-normalize.ts +2 -1
- package/src/mcpl/transport.ts +162 -33
- package/src/mcpl/types.ts +23 -6
- package/src/module-registry.ts +92 -13
- package/src/modules/discord/index.ts +8 -13
- package/src/modules/history/index.ts +25 -3
- package/src/modules/history/semantic.ts +29 -6
- package/src/modules/index.ts +1 -0
- package/src/modules/unknown-tool.ts +4 -1
- package/src/modules/workspace/branch-intent.ts +3 -1
- package/src/modules/workspace/edit-plan.ts +954 -0
- package/src/modules/workspace/edit-text.ts +618 -0
- package/src/modules/workspace/effects.ts +70 -19
- package/src/modules/workspace/index.ts +2247 -642
- package/src/modules/workspace/observe.ts +465 -56
- package/src/modules/workspace/reconcile.ts +14 -7
- package/src/modules/workspace/text.ts +68 -0
- package/src/modules/workspace/types.ts +211 -6
- package/src/modules/workspace/watcher.ts +3 -2
- package/src/operator-log.ts +47 -19
- package/src/recovery/discord-awareness-outbox.ts +1500 -356
- package/src/recovery/offline-branch.ts +189 -55
- package/src/recovery/recover-cli.ts +25 -4
- package/src/send-tools.ts +11 -0
- package/src/tool-result-guard.ts +6 -5
- package/src/tool-result-history.ts +1 -1
- package/src/tune-out/coordinator.ts +163 -42
- package/src/types/agent.ts +4 -0
- package/src/types/events.ts +62 -9
- package/src/types/framework.ts +62 -58
- package/src/types/module.ts +40 -8
- package/src/types/trace.ts +121 -15
- package/src/usage/types.ts +16 -0
- package/src/usage/usage-tracker.ts +86 -43
- package/dist/src/code-execution/py-runner.d.ts +0 -191
- package/dist/src/code-execution/py-runner.d.ts.map +0 -1
- package/dist/src/code-execution/py-runner.js +0 -655
- package/dist/src/code-execution/py-runner.js.map +0 -1
- package/dist/src/code-execution/runtime-py.d.ts +0 -50
- package/dist/src/code-execution/runtime-py.d.ts.map +0 -1
- package/dist/src/code-execution/runtime-py.js +0 -677
- package/dist/src/code-execution/runtime-py.js.map +0 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +0 -88
- package/dist/src/mcpl/prose-stream-router.d.ts.map +0 -1
- package/dist/src/mcpl/prose-stream-router.js +0 -291
- package/dist/src/mcpl/prose-stream-router.js.map +0 -1
- package/src/code-execution/py-runner.ts +0 -791
- package/src/code-execution/runtime-py.ts +0 -677
- package/src/mcpl/prose-stream-router.ts +0 -268
|
@@ -37,14 +37,14 @@ import type {
|
|
|
37
37
|
import type { McplServerRegistry } from './server-registry.js';
|
|
38
38
|
import type { FeatureSetManager } from './feature-set-manager.js';
|
|
39
39
|
import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js';
|
|
40
|
-
import { expandCoreTags } from './tags.js';
|
|
40
|
+
import { expandCoreTags, isMarkerTagged } from './tags.js';
|
|
41
41
|
import { coalesceRejectionReason, EmptyContentError, validateCoalescedContent } from './push-coalescer.js';
|
|
42
42
|
import { isVisiblyEmptyConversion } from './visible-content.js';
|
|
43
43
|
import { logDetail, logValue } from './log-value.js';
|
|
44
44
|
import { RefusalLines } from './refusal-lines.js';
|
|
45
45
|
import { CapabilityGrant } from './capability-grant.js';
|
|
46
46
|
import type { InboundSource } from './inbound-source.js';
|
|
47
|
-
import { McplRequestError } from './server-connection.js';
|
|
47
|
+
import { McplRequestError, type McplServerConnection } from './server-connection.js';
|
|
48
48
|
import { readSkipReplyWake } from './skip-reply-wake.js';
|
|
49
49
|
|
|
50
50
|
// ============================================================================
|
|
@@ -54,14 +54,97 @@ import { readSkipReplyWake } from './skip-reply-wake.js';
|
|
|
54
54
|
const TYPING_INTERVAL_MS = 7_000;
|
|
55
55
|
const MAX_CHANNEL_LIFECYCLE_ATTEMPTS = 5;
|
|
56
56
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Where a channel's server stands for a request this host would make of it
|
|
59
|
+
* (ChannelRegistry.serverReach). Only an `up` server's grant says what it
|
|
60
|
+
* allows: a connection's grant is empty from a transport drop until its next
|
|
61
|
+
* policy exchange settles, whatever the server will allow then.
|
|
62
|
+
* - `gone`: not registered, with no connect of its id in flight (closed for
|
|
63
|
+
* good without reconnect, whose channels stay registered here, or removed),
|
|
64
|
+
* or registered, closed and not reconnecting (one kept for a child it
|
|
65
|
+
* couldn't reap), with no restart of it in flight. This host doesn't bring
|
|
66
|
+
* it back on its own.
|
|
67
|
+
* - `connecting`: a connect or restart of its id in flight, or registered
|
|
68
|
+
* and connected, with the policy exchange of the transport it's on still
|
|
69
|
+
* in flight (the host's serverConnecting).
|
|
70
|
+
* - `down`: registered, between transports, and reconnecting.
|
|
71
|
+
* - `up`: connected, its exchange settled, granted or not, refused or
|
|
72
|
+
* unanswered: the grant it holds is that transport's answer.
|
|
73
|
+
*/
|
|
74
|
+
type ServerReach = 'gone' | 'connecting' | 'down' | 'up';
|
|
75
|
+
|
|
76
|
+
/** What a lifecycle failure tells the agent it can do next
|
|
77
|
+
* (ChannelRegistry.lifecycleFailureText, openFailedReason). */
|
|
78
|
+
type ChannelLifecycleFailure =
|
|
79
|
+
| 'unreachable'
|
|
80
|
+
| 'not-permitted'
|
|
81
|
+
| 'cancelled'
|
|
82
|
+
| 'unlisted'
|
|
83
|
+
| 'superseded'
|
|
84
|
+
| 'converge'
|
|
85
|
+
| 'substituted'
|
|
86
|
+
| 'removed'
|
|
87
|
+
| 'not-open'
|
|
88
|
+
| 'unrecorded'
|
|
89
|
+
| 'overtaken'
|
|
90
|
+
| 'put-back-unrecorded'
|
|
91
|
+
| 'selected-again';
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A channel's lifecycle operation, or a delivery waiting on one, that didn't
|
|
95
|
+
* complete. Its message is a clause in the agent's words, which its traces
|
|
96
|
+
* carry too, so the two never drift apart; `kind` says what the agent can do
|
|
97
|
+
* next, and `reach` where the server stood, for `unreachable`.
|
|
98
|
+
* - `unreachable`: its server isn't up (ServerReach).
|
|
99
|
+
* - `not-permitted`: its server is up, and its grant lacks channels.lifecycle.
|
|
100
|
+
* - `cancelled`: queued for a server whose registrations were removed.
|
|
101
|
+
* - `unlisted`: the server no longer lists the channel.
|
|
102
|
+
* - `superseded`: a later decision about the channel came first.
|
|
103
|
+
* - `converge`: a successful RPC kept failing to establish the latest target.
|
|
104
|
+
* - `substituted`: a channels/open answered for a channel other than the one
|
|
105
|
+
* it named.
|
|
106
|
+
* - `removed`, `not-open`: a delivery's wait (waitForOpenChannel) ended with
|
|
107
|
+
* the channel's registration gone, or with its close standing
|
|
108
|
+
* (closeStands) or its open not confirmed. These are whole reasons for
|
|
109
|
+
* words that didn't go out, said as they are.
|
|
110
|
+
* - `unrecorded`: the decision couldn't be written to the lifecycle log
|
|
111
|
+
* (appendLifecycleEvent), so nothing changed and nothing was sent.
|
|
112
|
+
* - `overtaken`, `put-back-unrecorded`, `selected-again`: an open by speech
|
|
113
|
+
* or a reply that failed (openChannelNow), carrying its own failure's
|
|
114
|
+
* clause. `overtaken`: a decision made during its round-trip, such as the
|
|
115
|
+
* agent's own close or channel_open, replaced the one the open wrote; that
|
|
116
|
+
* decision stands, and nothing is put back. `put-back-unrecorded`: the
|
|
117
|
+
* decision it replaced couldn't be put back on record, so the decision to
|
|
118
|
+
* open stays, the channel stays selected for opening, and reconciliation
|
|
119
|
+
* may open it later. `selected-again`: the pure default it put back is
|
|
120
|
+
* lifted by the channel's current descriptor or the subscription policy,
|
|
121
|
+
* so the channel is selected for opening again; `by` says which.
|
|
122
|
+
*/
|
|
123
|
+
class ChannelLifecycleError extends Error {
|
|
124
|
+
readonly by?: Exclude<SelectedForOpening, 'unrecorded'>;
|
|
125
|
+
constructor(
|
|
126
|
+
message: string,
|
|
127
|
+
readonly kind: ChannelLifecycleFailure,
|
|
128
|
+
readonly reach?: Exclude<ServerReach, 'up'>,
|
|
129
|
+
options?: { by?: Exclude<SelectedForOpening, 'unrecorded'>; cause?: unknown },
|
|
130
|
+
) {
|
|
131
|
+
super(message, options && 'cause' in options ? { cause: options.cause } : undefined);
|
|
132
|
+
this.name = 'ChannelLifecycleError';
|
|
133
|
+
this.by = options?.by;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Why a channel is selected for opening after a failed open by speech or a
|
|
138
|
+
* reply: its put-back couldn't be recorded, or the server's latest
|
|
139
|
+
* announcement or the subscription policy lifted the default it restored. */
|
|
140
|
+
export type SelectedForOpening = 'unrecorded' | 'server-bootstrap' | 'subscription-policy';
|
|
141
|
+
|
|
63
142
|
const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle';
|
|
64
143
|
|
|
144
|
+
/** The sources of a decision the agent itself made: channel_open and
|
|
145
|
+
* channel_close, and channel_decline. */
|
|
146
|
+
const AGENT_DECISION_SOURCES: ReadonlySet<string> = new Set(['agent-tool', 'invitation-declined']);
|
|
147
|
+
|
|
65
148
|
/**
|
|
66
149
|
* What a channels/open answer says about which channel the server opened:
|
|
67
150
|
* its `channel.id`, or else the `channelId` of a history item. Each is
|
|
@@ -116,8 +199,11 @@ export interface TuneOutParams {
|
|
|
116
199
|
epochId: string;
|
|
117
200
|
/** Subconscious summary cadence, in seconds. */
|
|
118
201
|
cadenceSeconds: number;
|
|
119
|
-
/** Maximum messages dumped raw at cancel
|
|
120
|
-
*
|
|
202
|
+
/** Maximum messages dumped raw at cancel, the newest. Where the residents'
|
|
203
|
+
* strategies cap a message's size, the dump holds as many as fit whole in
|
|
204
|
+
* one message, or, when even the newest doesn't, as much of the newest as
|
|
205
|
+
* fits beside the cut's mark, if any does. For those left out the
|
|
206
|
+
* subconscious curates a digest, and `fetch_history` covers them. */
|
|
121
207
|
backlogCap: number;
|
|
122
208
|
/** Wake invocations before the tune-out auto-cancels. */
|
|
123
209
|
maxWakes: number;
|
|
@@ -152,6 +238,43 @@ interface ChannelLifecycleEvent {
|
|
|
152
238
|
acknowledgment?: string;
|
|
153
239
|
}
|
|
154
240
|
|
|
241
|
+
/** A lifecycle-log item as a desired-state decision, or null when it isn't
|
|
242
|
+
* one this build reads: the one rule the registry's replay
|
|
243
|
+
* (initializeLifecycleStore) and desiredStateInLog read the log by. */
|
|
244
|
+
function desiredStateRecord(item: unknown): {
|
|
245
|
+
serverId: string; channelId: string; state: DesiredChannelState; source: string; tuneOut?: TuneOutParams;
|
|
246
|
+
} | null {
|
|
247
|
+
const event = item as Partial<ChannelLifecycleEvent> | null | undefined;
|
|
248
|
+
if (event?.kind !== 'desired-state' || typeof event.serverId !== 'string' || typeof event.channelId !== 'string') return null;
|
|
249
|
+
if (!(event.desired === 'open' || event.desired === 'closed' || (event.desired === 'tuned-out' && event.tuneOut))) return null;
|
|
250
|
+
return {
|
|
251
|
+
serverId: event.serverId,
|
|
252
|
+
channelId: event.channelId,
|
|
253
|
+
state: event.desired,
|
|
254
|
+
source: typeof event.source === 'string' ? event.source : 'unknown',
|
|
255
|
+
...(event.desired === 'tuned-out' ? { tuneOut: event.tuneOut } : {}),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The desired state `store`'s lifecycle log records for `channelId` on
|
|
261
|
+
* `serverId`, for a reader with no registry yet, as a serving boot's
|
|
262
|
+
* deferred-write flush is, before initializeMcpl: the last decision wins, as
|
|
263
|
+
* the registry's replay reads it (getDesiredState answers once there is a
|
|
264
|
+
* registry). undefined when no decision names it.
|
|
265
|
+
*/
|
|
266
|
+
export function desiredStateInLog(store: JsStore, serverId: string, channelId: string): DesiredChannelState | undefined {
|
|
267
|
+
ensureStateRegistered(store, { id: CHANNEL_LIFECYCLE_LOG_ID, strategy: 'append_log' });
|
|
268
|
+
const raw = store.getStateJson(CHANNEL_LIFECYCLE_LOG_ID);
|
|
269
|
+
if (!Array.isArray(raw)) return undefined;
|
|
270
|
+
let state: DesiredChannelState | undefined;
|
|
271
|
+
for (const item of raw) {
|
|
272
|
+
const decision = desiredStateRecord(item);
|
|
273
|
+
if (decision?.serverId === serverId && decision.channelId === channelId) state = decision.state;
|
|
274
|
+
}
|
|
275
|
+
return state;
|
|
276
|
+
}
|
|
277
|
+
|
|
155
278
|
/** One append-log record in `CHANNEL_LABEL_HISTORY_LOG_ID`: "at `ts`, this
|
|
156
279
|
* channelId's label was observed to be `label`". See `labelHistory`. */
|
|
157
280
|
interface ChannelLabelSightingEvent {
|
|
@@ -252,6 +375,29 @@ interface ChannelEntry {
|
|
|
252
375
|
open: boolean;
|
|
253
376
|
}
|
|
254
377
|
|
|
378
|
+
/**
|
|
379
|
+
* A channel its server declared inbound-only (SPEC §14.2 `direction`): its
|
|
380
|
+
* messages come in, and the host publishes nothing into it. No publish, from
|
|
381
|
+
* plain speech, `channel_publish` or `publishForAgent`, and no outgoing
|
|
382
|
+
* stream starts there, though one the channel took before the declaration
|
|
383
|
+
* ends there (sendOutgoingChunk). Typing and acknowledgements, which carry
|
|
384
|
+
* no content, still go (SPEC §14.5: "Delivery occurs only via
|
|
385
|
+
* `channels/publish`"). A channel declared `outbound` or `bidirectional`, or
|
|
386
|
+
* with no direction, isn't one.
|
|
387
|
+
*/
|
|
388
|
+
function isInboundOnly(entry: ChannelEntry): boolean {
|
|
389
|
+
return entry.descriptor.direction === 'inbound';
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Why the host sends nothing into an inbound-only channel, as the resident
|
|
393
|
+
* reads it in a send-failure marker or a tool error. */
|
|
394
|
+
function inboundOnlyReason(entry: ChannelEntry): string {
|
|
395
|
+
return (
|
|
396
|
+
`inbound-only: server "${entry.serverId}" declared this channel's direction "inbound", ` +
|
|
397
|
+
'so the host publishes nothing into it; to speak there, use that server\'s own tools'
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
255
401
|
function sameChannelTarget(a: ChannelDescriptor, b: ChannelDescriptor): boolean {
|
|
256
402
|
return a.type === b.type && isDeepStrictEqual(a.address, b.address);
|
|
257
403
|
}
|
|
@@ -306,6 +452,33 @@ export function journalToolDescription(canSkipReply: boolean): string {
|
|
|
306
452
|
'text is routed' + (canSkipReply ? '; to end the turn without replying, call skip_reply afterwards.' : '.');
|
|
307
453
|
}
|
|
308
454
|
|
|
455
|
+
/**
|
|
456
|
+
* channel_open's and channel_close's descriptions for an agent whose window doesn't hold a channel's traffic: an
|
|
457
|
+
* ephemeral run, whose store is its own unless it's where channel messages are stored (on a host with no residents,
|
|
458
|
+
* the first registered agent), and on a host that routes conversations every agent but a fork, since a channel's
|
|
459
|
+
* messages go to its fork or, unrouted, nowhere. The descriptions in CHANNEL_TOOL_DEFINITIONS, written for the agents
|
|
460
|
+
* that read a channel, promised such an agent what it never gets: "Messages that address you still reach you", and
|
|
461
|
+
* history "preceding the message that invited you" when nothing invites it. Given per agent (AgentFramework's
|
|
462
|
+
* getToolsForAgent), so a holder's cached tool list doesn't change.
|
|
463
|
+
*
|
|
464
|
+
* Both say the call is for every agent: the decision is recorded per server and channel (setDesiredState), so a run's
|
|
465
|
+
* close closes the channel for the agents whose windows hold it, which "its messages don't come to you" alone could
|
|
466
|
+
* read as costing nothing. They claim only what holds on both lanes: a push that names a channel is stored in the
|
|
467
|
+
* residents' shared slot whether or not the channel is open (handleMcplPushEvent), so on a routed host the residents
|
|
468
|
+
* get the channel's pushes, and a server may push while a channel is closed and send channels/incoming once it's
|
|
469
|
+
* open. So opening brings the caller none of the channel's messages, and closing stops none reaching it. The history
|
|
470
|
+
* channel_open asks for comes back in its result (handleToolOpen), so that clause is true for anyone.
|
|
471
|
+
*/
|
|
472
|
+
export const NON_HOLDER_CHANNEL_DESCRIPTIONS: Readonly<Record<'channel_open' | 'channel_close', string>> = {
|
|
473
|
+
channel_open:
|
|
474
|
+
'Open a channel for every agent on this host. Opening it brings you none of its messages; for the agents they do ' +
|
|
475
|
+
'come to, its server decides what it delivers. Optionally request some of its history, which comes back to you in ' +
|
|
476
|
+
'this call.',
|
|
477
|
+
channel_close:
|
|
478
|
+
'Close a channel for every agent on this host. Closing it stops none of its messages reaching you; for the agents ' +
|
|
479
|
+
'they do come to, its server decides what closing it stops.',
|
|
480
|
+
};
|
|
481
|
+
|
|
309
482
|
const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
310
483
|
{
|
|
311
484
|
name: 'channel_list',
|
|
@@ -370,11 +543,11 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
370
543
|
},
|
|
371
544
|
{
|
|
372
545
|
name: 'channel_publish',
|
|
373
|
-
description:
|
|
546
|
+
description: "Publish a message to a channel. If channelId is omitted, publishes to your conversation's channel if you are bound to one; otherwise to the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it. Reactions, reaction removals, deletions and edits don't set it, though an edit of a message you hadn't been shown can count as that message. A message pushed to you rather than posted on a channel never sets it. A subagent has neither default, so it must name a channel.",
|
|
374
547
|
inputSchema: {
|
|
375
548
|
type: 'object' as const,
|
|
376
549
|
properties: {
|
|
377
|
-
channelId: { type: 'string', description:
|
|
550
|
+
channelId: { type: 'string', description: "ID of the channel to publish to (defaults to your conversation's channel if you are bound to one; otherwise the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it, other than a reaction, reaction removal, deletion or edit, though an edit of a message you hadn't been shown can count; a message pushed to you rather than posted on a channel never sets it; required of a subagent)" },
|
|
378
551
|
content: { type: 'string', description: 'Text content to publish' },
|
|
379
552
|
text: { type: 'string', description: 'Alias for content' },
|
|
380
553
|
},
|
|
@@ -450,7 +623,8 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
450
623
|
// ============================================================================
|
|
451
624
|
|
|
452
625
|
/** Bound connector values for notices, logs and an outcome's reason. A
|
|
453
|
-
* PublishOutcome keeps the connector's structured data (`detail`)
|
|
626
|
+
* PublishOutcome keeps the connector's structured data (`detail`) as the
|
|
627
|
+
* connection gives it, its strings redacted (redactedData). */
|
|
454
628
|
export function connectorText(value: unknown): string {
|
|
455
629
|
let text: string;
|
|
456
630
|
try {
|
|
@@ -472,6 +646,12 @@ export interface PublishDestination {
|
|
|
472
646
|
label?: string;
|
|
473
647
|
}
|
|
474
648
|
|
|
649
|
+
/** One key per destination, its server and channel: unlike joining the two
|
|
650
|
+
* with a separator, no two destinations share one, whatever their ids hold. */
|
|
651
|
+
export function destinationKey(to: PublishDestination): string {
|
|
652
|
+
return JSON.stringify([to.serverId, to.channelId]);
|
|
653
|
+
}
|
|
654
|
+
|
|
475
655
|
/**
|
|
476
656
|
* What one channels/publish attempt established (ChannelRegistry.publish).
|
|
477
657
|
* - `delivered`: the connector returned `delivered: true`. A historical
|
|
@@ -494,12 +674,172 @@ export interface PublishOutcome {
|
|
|
494
674
|
/** Why it failed, or why its outcome is unknown — for a connector error,
|
|
495
675
|
* its message, bounded (which may say what was already posted). */
|
|
496
676
|
reason?: string;
|
|
497
|
-
/** The connector error's structured `data`,
|
|
677
|
+
/** The connector error's structured `data`, with the strings in it
|
|
678
|
+
* redacted (redactedData), when it sent one. */
|
|
498
679
|
detail?: unknown;
|
|
499
680
|
/** When the outcome was established (epoch ms). */
|
|
500
681
|
at: number;
|
|
501
682
|
}
|
|
502
683
|
|
|
684
|
+
/**
|
|
685
|
+
* Why the destination refused a publishForAgent before anything went out.
|
|
686
|
+
* Its words say the same, since both come from one check: the channel isn't
|
|
687
|
+
* registered here, it takes no posts, or its server is gone, this host is
|
|
688
|
+
* connecting to it, it's reconnecting, or it's up without channels.publish
|
|
689
|
+
* (serverReach); or the request wasn't sent, as its connection refused it
|
|
690
|
+
* before anything was written (McplRequestError's `not-sent`). A refusal of
|
|
691
|
+
* the caller's own input, such as no text or no channel id, carries none,
|
|
692
|
+
* and so does a publish that went out, whatever became of it: its words say
|
|
693
|
+
* whether it may have been posted.
|
|
694
|
+
*/
|
|
695
|
+
export type PublishRefusalReason =
|
|
696
|
+
| 'channel-not-registered'
|
|
697
|
+
| 'inbound-only'
|
|
698
|
+
| 'server-gone'
|
|
699
|
+
| 'server-connecting'
|
|
700
|
+
| 'server-down'
|
|
701
|
+
| 'not-permitted'
|
|
702
|
+
| 'not-sent';
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* A failed request's words, bounded, for a reason that a caller composes.
|
|
706
|
+
* This host's own words, for a request that wasn't sent or wasn't answered
|
|
707
|
+
* or for an error of its own, such as its store's, are sentences, and every
|
|
708
|
+
* composition of a reason adds its own ending, so one final period comes
|
|
709
|
+
* off them. A server's error answer keeps its words as they came.
|
|
710
|
+
*/
|
|
711
|
+
function requestErrorText(err: unknown): string {
|
|
712
|
+
const text = connectorText(err instanceof Error ? err.message : String(err));
|
|
713
|
+
return err instanceof McplRequestError && err.outcome === 'error-response' ? text : text.replace(/(?<!\.)\.$/, '');
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** A server that isn't up (ServerReach), as a clause the agent reads. */
|
|
717
|
+
function unreachableClause(serverId: string, reach: Exclude<ServerReach, 'up'>): string {
|
|
718
|
+
switch (reach) {
|
|
719
|
+
case 'gone': return `the "${serverId}" server isn't connected, and this host won't reconnect to it on its own`;
|
|
720
|
+
case 'connecting': return `this host is connecting to the "${serverId}" server`;
|
|
721
|
+
case 'down': return `the "${serverId}" server isn't connected, and this host is reconnecting to it`;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** What helps when a server isn't up (ServerReach), as the end of the
|
|
726
|
+
* clause that says so. */
|
|
727
|
+
function unreachableRemedy(reach: Exclude<ServerReach, 'up'>): string {
|
|
728
|
+
switch (reach) {
|
|
729
|
+
case 'gone': return '; if you need it, ask whoever runs this host';
|
|
730
|
+
case 'connecting': return ', so try again in a moment';
|
|
731
|
+
case 'down': return ', so try again later';
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** Whether an entry's descriptor carries the key a push's placeholder has
|
|
736
|
+
* (ensureChannelRegistered's lazyRegistered metadata), as main reads it for
|
|
737
|
+
* whether the server has declared the channel: a registration or a
|
|
738
|
+
* channels/changed update brings the server's own descriptor, which
|
|
739
|
+
* doesn't normally carry it. The traffic rule doesn't read it, since a
|
|
740
|
+
* server's descriptor can (untargetedPushEntries). */
|
|
741
|
+
function pushPlaceholder(entry: ChannelEntry): boolean {
|
|
742
|
+
return (entry.descriptor.metadata as { lazyRegistered?: boolean } | undefined)?.lazyRegistered === true;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** When reconciling applies a recorded decision: at the server's next
|
|
746
|
+
* registration of the channel, where this host directs its channels'
|
|
747
|
+
* lifecycle then (reconcileChannels, §14.1). A channel made from a push
|
|
748
|
+
* may never be registered at all, so for one the text says "if". */
|
|
749
|
+
function laterClause(entry: ChannelEntry): string {
|
|
750
|
+
return pushPlaceholder(entry)
|
|
751
|
+
? `if the "${entry.serverId}" server registers this channel at a time this host may open and close its channels`
|
|
752
|
+
: `when the "${entry.serverId}" server next registers this channel, if this host may open and close its channels then`;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** A channel decision the lifecycle log refused to append, as on a full
|
|
756
|
+
* disk: nothing changed (appendLifecycleEvent). */
|
|
757
|
+
function unrecordedDecision(err: unknown): ChannelLifecycleError {
|
|
758
|
+
return new ChannelLifecycleError(`this host couldn't record the decision: ${requestErrorText(err)}`, 'unrecorded');
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** A failure's clause, then more said of the same failure. A server's own
|
|
762
|
+
* words keep their final stop (requestErrorText), so after them the rest
|
|
763
|
+
* is a sentence of its own. */
|
|
764
|
+
function afterClause(clause: string, more: string): string {
|
|
765
|
+
return /[.!?]$/.test(clause) ? `${clause} ${more.charAt(0).toUpperCase()}${more.slice(1)}` : `${clause}; ${more}`;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** A lifecycle failure as a clause: its own words, or its request's. */
|
|
769
|
+
function lifecycleClause(err: unknown): string {
|
|
770
|
+
return err instanceof ChannelLifecycleError ? err.message : requestErrorText(err);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Why words didn't go out when their channel's registration went away
|
|
774
|
+
* first, at a delivery's wait or at its last check. */
|
|
775
|
+
const CHANNEL_REMOVED_CLAUSE = 'the channel was removed before these words went out';
|
|
776
|
+
|
|
777
|
+
/** A tune-out's open answered for a tune-out that ended, or that a later
|
|
778
|
+
* decision or a later tune-out replaced, while the open was out: by
|
|
779
|
+
* openForTuneOut, and by a repeated tune_out whose tune-out isn't the one it
|
|
780
|
+
* read before it waited. */
|
|
781
|
+
export const TUNE_OUT_REPLACED = "The tune-out was recorded, but it isn't in effect any more: it ended, or a later " +
|
|
782
|
+
'decision about the channel or a later tune-out replaced it, while its transport was being opened.';
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Why words a send opened their channel for didn't go out. The delivery's
|
|
786
|
+
* wait after the open says it whole (`removed`, `not-open`). The open's own
|
|
787
|
+
* failure is said as one, with what helps where there's a step to take. It
|
|
788
|
+
* names no state of the channel's transport: one decided closed can still
|
|
789
|
+
* be open, after a close that failed.
|
|
790
|
+
*/
|
|
791
|
+
function openFailedReason(err: unknown): string {
|
|
792
|
+
if (err instanceof ChannelLifecycleError && (err.kind === 'removed' || err.kind === 'not-open')) return err.message;
|
|
793
|
+
return `opening the channel for these words didn't complete: ${lifecycleClause(err)}${refusalRemedy(err)}`;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** What helps after a lifecycle failure, as the end of the clause that says
|
|
797
|
+
* it: the server state's remedy, whom to ask for a grant, or another try
|
|
798
|
+
* where the channel's target kept changing or the decision couldn't be
|
|
799
|
+
* recorded. Nothing for any other failure. */
|
|
800
|
+
function refusalRemedy(err: unknown): string {
|
|
801
|
+
if (!(err instanceof ChannelLifecycleError)) return '';
|
|
802
|
+
if (err.kind === 'unreachable' && err.reach) return unreachableRemedy(err.reach);
|
|
803
|
+
if (err.kind === 'not-permitted') return '; if you need it, ask whoever runs this host';
|
|
804
|
+
if (err.kind === 'converge') return ', so try again';
|
|
805
|
+
if (err.kind === 'unrecorded') return ', so try again; if it fails the same way, ask whoever runs this host';
|
|
806
|
+
return '';
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** Why an open or a close doesn't stand when a later decision about the
|
|
810
|
+
* channel came first (`now`, its desired state since). */
|
|
811
|
+
function supersededClause(action: 'open' | 'close', now: DesiredChannelState | undefined): string {
|
|
812
|
+
if (now === 'tuned-out') return 'it was tuned out meanwhile';
|
|
813
|
+
return action === 'close' ? 'a later decision to keep it open came first' : 'a later decision to close it came first';
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** A channel tool's refusal of a tuned-out channel, whose cancel is what ends
|
|
817
|
+
* the tune-out, with the backlog it delivers. As an error result the agent
|
|
818
|
+
* reads the pointer itself; `data` names the refusal for an in-process
|
|
819
|
+
* caller. */
|
|
820
|
+
function tunedOutRefusal(tool: string, channelId: string): ToolResult {
|
|
821
|
+
return {
|
|
822
|
+
success: false,
|
|
823
|
+
isError: true,
|
|
824
|
+
error:
|
|
825
|
+
`Channel "${channelId}" is tuned out: its messages go to the subconscious, and ${tool} doesn't change that. ` +
|
|
826
|
+
`Call tune_out with channelId "${channelId}" and mode "cancel" for it to resume normal attention; cancelling ` +
|
|
827
|
+
'delivers the diverted backlog.',
|
|
828
|
+
data: { refusal: 'tuned-out', channelId },
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** A clause as the start of a sentence. */
|
|
833
|
+
function sentenceStart(clause: string): string {
|
|
834
|
+
return clause.charAt(0).toUpperCase() + clause.slice(1);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** Words as a sentence's end: a stop, unless they end in one already, as a
|
|
838
|
+
* server's own words may. */
|
|
839
|
+
function withStop(text: string): string {
|
|
840
|
+
return /[.!?]$/.test(text) ? text : `${text}.`;
|
|
841
|
+
}
|
|
842
|
+
|
|
503
843
|
/**
|
|
504
844
|
* What a channels/publish request that threw establishes. Only a request
|
|
505
845
|
* that never left the host proves nothing was posted. An error response may
|
|
@@ -509,12 +849,7 @@ export interface PublishOutcome {
|
|
|
509
849
|
* a failed request this way.
|
|
510
850
|
*/
|
|
511
851
|
function publishErrorOutcome(err: unknown, destination: PublishDestination, at: number): PublishOutcome {
|
|
512
|
-
|
|
513
|
-
// are sentences, and every composition of the reason adds its own ending,
|
|
514
|
-
// so one final period comes off them. A server's error answer keeps its
|
|
515
|
-
// words as they came.
|
|
516
|
-
const text = connectorText(err instanceof Error ? err.message : String(err));
|
|
517
|
-
const message = err instanceof McplRequestError && err.outcome !== 'error-response' ? text.replace(/(?<!\.)\.$/, '') : text;
|
|
852
|
+
const message = requestErrorText(err);
|
|
518
853
|
const notSent = err instanceof McplRequestError && err.outcome === 'not-sent';
|
|
519
854
|
const detail = err instanceof McplRequestError ? err.data : undefined;
|
|
520
855
|
return {
|
|
@@ -543,16 +878,17 @@ function publishReceiptOutcome(result: unknown, destination: PublishDestination,
|
|
|
543
878
|
}
|
|
544
879
|
if (receipt.delivered === false) {
|
|
545
880
|
if (receipt.messageId === undefined || receipt.messageId === null) {
|
|
546
|
-
return { status: 'failed', destination, reason: `
|
|
881
|
+
return { status: 'failed', destination, reason: `the "${destination.serverId}" server said it didn't post the message`, at };
|
|
547
882
|
}
|
|
548
883
|
return {
|
|
549
884
|
status: 'unknown',
|
|
550
885
|
destination,
|
|
551
886
|
...(messageId ? { messageId } : {}),
|
|
552
887
|
reason: messageId
|
|
553
|
-
? `
|
|
554
|
-
|
|
555
|
-
|
|
888
|
+
? `the "${destination.serverId}" server said it didn't post the message, yet named message ` +
|
|
889
|
+
`${connectorText(messageId)} as posted (delivery uncertain)`
|
|
890
|
+
: `the "${destination.serverId}" server said it didn't post the message, along with a message id that ` +
|
|
891
|
+
`isn't valid (${connectorText(receipt.messageId)}) (delivery uncertain)`,
|
|
556
892
|
at,
|
|
557
893
|
};
|
|
558
894
|
}
|
|
@@ -560,7 +896,7 @@ function publishReceiptOutcome(result: unknown, destination: PublishDestination,
|
|
|
560
896
|
status: 'unknown',
|
|
561
897
|
destination,
|
|
562
898
|
...(messageId ? { messageId } : {}),
|
|
563
|
-
reason: `
|
|
899
|
+
reason: `the "${destination.serverId}" server's answer didn't say whether it posted the message (delivery uncertain)`,
|
|
564
900
|
at,
|
|
565
901
|
};
|
|
566
902
|
}
|
|
@@ -577,26 +913,34 @@ interface ChannelRegistryOptions {
|
|
|
577
913
|
* RFC-006: an admitted `channels/incoming` message carrying `coalesce`, with
|
|
578
914
|
* the event the ordinary path would have queued. The handler decides
|
|
579
915
|
* replace / append / withdraw and returns the per-message result. Throws a
|
|
580
|
-
* `CoalesceError` for a malformed or unauthorized occurrence.
|
|
916
|
+
* `CoalesceError` for a malformed or unauthorized occurrence. `firstView`
|
|
917
|
+
* says the coalescer delivered the message as the agent's first view of it,
|
|
918
|
+
* with the delivery's own `firstView` mark; it is the host's own, and isn't
|
|
919
|
+
* in the answer to the server.
|
|
581
920
|
*/
|
|
582
921
|
handleCoalescedIncoming?: (
|
|
583
922
|
serverId: string,
|
|
584
923
|
message: ChannelIncomingMessage,
|
|
585
924
|
event: McplChannelIncomingEvent,
|
|
586
|
-
) => Promise<ChannelIncomingMessageResult>;
|
|
925
|
+
) => Promise<ChannelIncomingMessageResult & { firstView?: true }>;
|
|
587
926
|
/** Chronicle store used for durable desired channel lifecycle state. */
|
|
588
927
|
store?: JsStore;
|
|
589
928
|
/** Callback to determine whether an incoming message should trigger inference. */
|
|
590
929
|
shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
|
|
591
930
|
/**
|
|
592
|
-
* Called when a text-only turn's speech
|
|
593
|
-
* conversational locus
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
931
|
+
* Called when a text-only turn's speech was not delivered to its
|
|
932
|
+
* conversational locus (`failed`), or may not have been (`unknown`), for
|
|
933
|
+
* whatever reason deliverSpeech gives, such as no locus, a channel that
|
|
934
|
+
* isn't registered or refuses speech, a missing server or grant, a failed
|
|
935
|
+
* open, the server reporting `delivered: false`, or a publish answered
|
|
936
|
+
* with an error, a timeout or a lost connection, after which whether it
|
|
937
|
+
* was posted is unknown. It isn't called for an ephemeral run's speech
|
|
938
|
+
* whose turn has no locus (a null locus), which is its caller's result
|
|
939
|
+
* (`takesGlobalFallback`), nor for a `failed` delivery whose caller holds
|
|
940
|
+
* its words with a notice of its own (deliverSpeech's `heldOnFailure`).
|
|
941
|
+
* The host wires this to drop a `[discord-send-failed]` marker into
|
|
942
|
+
* chronicle so the failure is visible to the agent (and operator) rather
|
|
943
|
+
* than silently lost. Must not itself trigger inference (avoid wake loops).
|
|
600
944
|
*/
|
|
601
945
|
onRouteFailure?: (info: {
|
|
602
946
|
conversationId: string;
|
|
@@ -632,9 +976,11 @@ interface ChannelRegistryOptions {
|
|
|
632
976
|
* forks are spawned bound to a single channel (the framework tracks this in
|
|
633
977
|
* `conversationAgentHomes` / `ConversationRouter.channelForAgent`); their
|
|
634
978
|
* plain-text speech must route THERE. The process-global `defaultPublishChannel`
|
|
635
|
-
* tracks only the most
|
|
636
|
-
*
|
|
637
|
-
*
|
|
979
|
+
* tracks only the most recent inbound message across ALL channels (reaction,
|
|
980
|
+
* reaction-removal, edit and deletion markers aside, apart from an edit the
|
|
981
|
+
* coalescer delivers on acceptance as the agent's first view), so with one
|
|
982
|
+
* fork per channel running concurrently it misroutes a fork's reply to
|
|
983
|
+
* whichever channel last spoke (item 3). Returns undefined for the trunk/primary agent, which has
|
|
638
984
|
* no home and correctly falls back to the global locus (heartbeats, etc.).
|
|
639
985
|
*/
|
|
640
986
|
homeChannelResolver?: (agentName: string) => string | undefined;
|
|
@@ -644,11 +990,12 @@ interface ChannelRegistryOptions {
|
|
|
644
990
|
* turn. This is the fix for single-TRUNK agents (the only mode
|
|
645
991
|
* connectome-host runs — it never exposes conversation forks). A trunk agent
|
|
646
992
|
* has no fork home, so without this its plain-text speech falls back to the
|
|
647
|
-
* process-global `defaultPublishChannel`, which tracks the most
|
|
648
|
-
* across ALL channels and misroutes a reply to
|
|
649
|
-
* under concurrency (item-3 redux). The
|
|
650
|
-
* turn's routing state and exposes them
|
|
651
|
-
* turn it resolves to undefined until the
|
|
993
|
+
* process-global `defaultPublishChannel`, which tracks the most recent inbound
|
|
994
|
+
* message across ALL channels (markers aside) and misroutes a reply to
|
|
995
|
+
* whichever channel last spoke under concurrency (item-3 redux). The
|
|
996
|
+
* framework keeps both on the live turn's routing state and exposes them
|
|
997
|
+
* here. For a heartbeat / no-trigger turn it resolves to undefined until the
|
|
998
|
+
* turn's locus is resolved, so that
|
|
652
999
|
* resolution takes the global fallback, which the turn then keeps.
|
|
653
1000
|
* Consulted AFTER `homeChannelResolver` (a fork's home always wins), BEFORE
|
|
654
1001
|
* `defaultPublishChannel`.
|
|
@@ -668,6 +1015,14 @@ interface ChannelRegistryOptions {
|
|
|
668
1015
|
takesGlobalFallback?: (conversationId: string) => boolean;
|
|
669
1016
|
/** Whether this framework has EventGate and can honor skip_reply self-wakes. */
|
|
670
1017
|
eventGatePresent?: boolean;
|
|
1018
|
+
/**
|
|
1019
|
+
* Whether this host is connecting to the server of this id: a connect or a
|
|
1020
|
+
* restart of the id is in flight, or the policy exchange of the transport
|
|
1021
|
+
* its registered connection is on is. Its channel tools and speech then say
|
|
1022
|
+
* so, as its tool calls do, rather than read the empty grant meanwhile as a
|
|
1023
|
+
* refusal (serverReach). It words a refusal only: the grant decides.
|
|
1024
|
+
*/
|
|
1025
|
+
serverConnecting?: (serverId: string) => boolean;
|
|
671
1026
|
}
|
|
672
1027
|
|
|
673
1028
|
// ============================================================================
|
|
@@ -711,11 +1066,20 @@ export class ChannelRegistry {
|
|
|
711
1066
|
private homeChannelResolver?: (agentName: string) => string | undefined;
|
|
712
1067
|
private activeChannelResolver?: (agentName: string) => string | undefined;
|
|
713
1068
|
private takesGlobalFallback?: (conversationId: string) => boolean;
|
|
1069
|
+
private serverConnecting?: (serverId: string) => boolean;
|
|
714
1070
|
private store?: JsStore;
|
|
715
1071
|
|
|
716
1072
|
/** Registered channels, keyed by `{serverId}:{channelId}`. */
|
|
717
1073
|
private channels = new Map<string, ChannelEntry>();
|
|
718
1074
|
|
|
1075
|
+
/**
|
|
1076
|
+
* Each outgoing stream, by `inferenceId`, with the destinations it has
|
|
1077
|
+
* started at (destinationKey): the rest of the stream there goes on there,
|
|
1078
|
+
* and its `complete` goes only there (sendOutgoingChunk). An entry goes
|
|
1079
|
+
* with the stream's `complete` for its destination.
|
|
1080
|
+
*/
|
|
1081
|
+
private takenStreams = new Map<string, Set<string>>();
|
|
1082
|
+
|
|
719
1083
|
/** Holds back repeats of the line for a message refused because its
|
|
720
1084
|
* channel isn't registered (RefusalLines). */
|
|
721
1085
|
private unknownChannelLines = new RefusalLines();
|
|
@@ -770,10 +1134,13 @@ export class ChannelRegistry {
|
|
|
770
1134
|
*/
|
|
771
1135
|
private dmClassified = new Map<string, true>();
|
|
772
1136
|
|
|
773
|
-
/**
|
|
1137
|
+
/** The channel of the most recent incoming message that isn't a reaction,
|
|
1138
|
+
* reaction removal, edit or deletion marker, apart from an edit the
|
|
1139
|
+
* coalescer delivers on acceptance as the agent's first view: used for
|
|
1140
|
+
* speech routing and the default publish. */
|
|
774
1141
|
private defaultPublishChannel: string | null = null;
|
|
775
1142
|
|
|
776
|
-
/**
|
|
1143
|
+
/** The same message's id and thread, used for buildChannelContext. */
|
|
777
1144
|
private defaultPublishMessageId: string | null = null;
|
|
778
1145
|
private defaultPublishThreadId: string | undefined = undefined;
|
|
779
1146
|
|
|
@@ -813,9 +1180,31 @@ export class ChannelRegistry {
|
|
|
813
1180
|
// target before confirming anything. Unlike the tail, which settles a few
|
|
814
1181
|
// microtasks after its operation's last check, this drops at that check.
|
|
815
1182
|
private lifecycleOutstanding = new Map<string, number>();
|
|
1183
|
+
// The removal count each channel's newest outstanding operation was queued
|
|
1184
|
+
// under. Where it's older than its server's, every operation still
|
|
1185
|
+
// outstanding for the channel cancels when it would run.
|
|
1186
|
+
private lifecycleQueuedRemovals = new Map<string, number>();
|
|
816
1187
|
// Losing an open receipt does not confirm closure. Keep that distinction
|
|
817
1188
|
// private while preserving the public open:boolean availability projection.
|
|
818
1189
|
private unconfirmedChannelTargets = new WeakSet<ChannelEntry>();
|
|
1190
|
+
// Marked entries a close failed for since a receipt or traffic last
|
|
1191
|
+
// confirmed them, cleared where the mark is: a failed request's outcome is
|
|
1192
|
+
// unknown, so such a close may have closed the channel, where an open,
|
|
1193
|
+
// failed or not, can't have. Only a tune-out reads it (openForTuneOut),
|
|
1194
|
+
// since its success says the channel's traffic will arrive.
|
|
1195
|
+
private failedCloseTargets = new WeakSet<ChannelEntry>();
|
|
1196
|
+
// Each tune-out's open in flight, by channel and epoch, so tune_out
|
|
1197
|
+
// repeated meanwhile waits for that open's answer rather than send another.
|
|
1198
|
+
private tuneOutOpens = new Map<string, Promise<string | undefined>>();
|
|
1199
|
+
// Entries ensureChannelRegistered made from a push, until a channels/changed
|
|
1200
|
+
// update gives one a target (handleChanged's retarget). A registration makes
|
|
1201
|
+
// a new entry, which isn't here. Kept by the host, not read from the
|
|
1202
|
+
// descriptor, whose metadata a server's own registration supplies.
|
|
1203
|
+
private untargetedPushEntries = new WeakSet<ChannelEntry>();
|
|
1204
|
+
// How many times each server's transport has closed (transportClosed).
|
|
1205
|
+
// handleIncoming reads it as a batch arrives, so traffic admitted after a
|
|
1206
|
+
// close it arrived before confirms nothing.
|
|
1207
|
+
private transportCloses = new Map<string, number>();
|
|
819
1208
|
|
|
820
1209
|
private invalidateTransportConfirmation(entry: ChannelEntry): void {
|
|
821
1210
|
entry.open = false;
|
|
@@ -825,16 +1214,205 @@ export class ChannelRegistry {
|
|
|
825
1214
|
/** Whether the host directs this server's channel lifecycle (§14.1: its
|
|
826
1215
|
* `channels.lifecycle` grant). Where it doesn't, the server opens and
|
|
827
1216
|
* closes its own channels, and sends no receipt the host could wait for. */
|
|
828
|
-
|
|
1217
|
+
directsLifecycle(serverId: string): boolean {
|
|
829
1218
|
return CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.lifecycle');
|
|
830
1219
|
}
|
|
831
1220
|
|
|
832
|
-
/**
|
|
833
|
-
*
|
|
834
|
-
|
|
1221
|
+
/** Whether a decided close holds words back from a channel: only where
|
|
1222
|
+
* the host directs the server's lifecycle. Without that grant the host
|
|
1223
|
+
* can't close the channel, and words go into a transport that is open,
|
|
1224
|
+
* as the open step's engagesDecidedClosed reads it too. */
|
|
1225
|
+
private closeStands(entry: ChannelEntry): boolean {
|
|
1226
|
+
return this.getDesiredState(entry.serverId, entry.descriptor.id) === 'closed' &&
|
|
1227
|
+
this.directsLifecycle(entry.serverId);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/** Where this server stands for a request this host would make of it
|
|
1231
|
+
* (ServerReach). One without a connection's state reads as connected.
|
|
1232
|
+
* A closed connection the host is restarting reads as connecting, as
|
|
1233
|
+
* one being connected does. */
|
|
1234
|
+
private serverReach(serverId: string): ServerReach {
|
|
1235
|
+
const server = this.serverRegistry.getServer(serverId);
|
|
1236
|
+
const connecting = this.serverConnecting?.(serverId) ?? false;
|
|
1237
|
+
if (!server) return connecting ? 'connecting' : 'gone';
|
|
1238
|
+
if (server.isConnected === false) return connecting ? 'connecting' : server.willReconnect ? 'down' : 'gone';
|
|
1239
|
+
return connecting ? 'connecting' : 'up';
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* Why this host won't open or close this server's channels, for a caller
|
|
1244
|
+
* that found its grant without channels.lifecycle (§14.1), which decides:
|
|
1245
|
+
* the server's state says why. It isn't up, or it's up without that grant.
|
|
1246
|
+
* Neither the grant's name, which is the operator's, nor an empty grant's
|
|
1247
|
+
* refusal, which needn't be the server's, reaches the agent.
|
|
1248
|
+
*/
|
|
1249
|
+
private lifecycleRefusal(serverId: string): ChannelLifecycleError {
|
|
1250
|
+
const reach = this.serverReach(serverId);
|
|
1251
|
+
if (reach !== 'up') return new ChannelLifecycleError(unreachableClause(serverId, reach), 'unreachable', reach);
|
|
1252
|
+
return new ChannelLifecycleError(`this host isn't permitted to open or close the "${serverId}" server's channels`, 'not-permitted');
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Why a send to this server's channels is refused before anything goes
|
|
1257
|
+
* out, as one clause with what works from there, for a caller that found
|
|
1258
|
+
* no server or a grant without channels.publish (§14.1), which decides:
|
|
1259
|
+
* the server's state says why. Plain speech gives it as its reason, and
|
|
1260
|
+
* channel_publish and speak_in_channel as theirs, so one refusal has one
|
|
1261
|
+
* wording. `checked` is the connection whose grant refused, where that
|
|
1262
|
+
* was read before the words waited: one the registry has replaced since
|
|
1263
|
+
* says nothing about the server that stands now.
|
|
1264
|
+
*/
|
|
1265
|
+
private sendRefusal(serverId: string, checked?: McplServerConnection): string {
|
|
1266
|
+
const now = this.serverRegistry.getServer(serverId);
|
|
1267
|
+
const reach = this.serverReach(serverId);
|
|
1268
|
+
// A replacement that would take the words is worth another try; one
|
|
1269
|
+
// that wouldn't is worded as it stands.
|
|
1270
|
+
if (checked && now && now !== checked && reach === 'up' && CapabilityGrant.of(now).has('channels.publish')) {
|
|
1271
|
+
return `the "${serverId}" server's connection was replaced while these words waited, so try again`;
|
|
1272
|
+
}
|
|
1273
|
+
if (reach !== 'up') return unreachableClause(serverId, reach) + unreachableRemedy(reach);
|
|
1274
|
+
return `this host isn't permitted to post to the "${serverId}" server's channels, and trying again won't change that; ` +
|
|
1275
|
+
'if you need it, ask whoever runs this host';
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* What a failed channel_open or channel_close tells the agent. Its
|
|
1280
|
+
* decision was recorded before the server was asked, and reconciling
|
|
1281
|
+
* applies it when the server next registers the channel (for a push's
|
|
1282
|
+
* placeholder, if it ever does), if this host directs their lifecycle then
|
|
1283
|
+
* (laterClause). A decision the log refused changed nothing, so that comes
|
|
1284
|
+
* first. A channel no longer registered
|
|
1285
|
+
* can't be reached by any channel tool, so its text names none. A later
|
|
1286
|
+
* decision that came first is what stands, so that comes next. Then the
|
|
1287
|
+
* server's state: one that isn't up gets the change when it's back, though
|
|
1288
|
+
* not a backscroll's history. For the request's own failure, its state is
|
|
1289
|
+
* read now, since a request out when the server dropped may have taken
|
|
1290
|
+
* effect, and reconciling settles it either way. A decision already in
|
|
1291
|
+
* effect, with no other work for the channel under way, says so, as one
|
|
1292
|
+
* whose backscroll alone failed is. Then the failure's kind, since each
|
|
1293
|
+
* has its own next step (ChannelLifecycleError). A request's own failure
|
|
1294
|
+
* is in the connection's words.
|
|
1295
|
+
*/
|
|
1296
|
+
private lifecycleFailureText(
|
|
1297
|
+
action: 'open' | 'close',
|
|
1298
|
+
entry: ChannelEntry,
|
|
1299
|
+
err: unknown,
|
|
1300
|
+
opts?: { history?: boolean },
|
|
1301
|
+
): string {
|
|
1302
|
+
const channel = `"${entry.descriptor.id}"`;
|
|
1303
|
+
const tool = action === 'open' ? 'channel_open' : 'channel_close';
|
|
1304
|
+
const done = action === 'open' ? 'opened' : 'closed';
|
|
1305
|
+
const later = laterClause(entry);
|
|
1306
|
+
// A decision never recorded is no decision a later one overtook, and
|
|
1307
|
+
// nothing about the channel changed, whatever its transport's state.
|
|
1308
|
+
if (err instanceof ChannelLifecycleError && err.kind === 'unrecorded') {
|
|
1309
|
+
return `Nothing changed for channel ${channel}: ${err.message}, so nothing was sent to the server. ` +
|
|
1310
|
+
`Calling ${tool} again tries again; if it fails the same way, ask whoever runs this host.`;
|
|
1311
|
+
}
|
|
1312
|
+
const failure = err instanceof ChannelLifecycleError ? err : undefined;
|
|
1313
|
+
const now = this.getDesiredState(entry.serverId, entry.descriptor.id);
|
|
1314
|
+
const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
|
|
1315
|
+
if (!current) {
|
|
1316
|
+
// Its registration may have gone at the server's word or at this
|
|
1317
|
+
// host's (removeServer). Only a failure that says which (cancelled,
|
|
1318
|
+
// unlisted) names it; otherwise the server's state where it isn't up,
|
|
1319
|
+
// and a request's own words where it failed with them. Another
|
|
1320
|
+
// failure's words are about a request whose channel is gone.
|
|
1321
|
+
const reach = this.serverReach(entry.serverId);
|
|
1322
|
+
const why = failure?.kind === 'cancelled' || failure?.kind === 'unlisted' ? `: ${failure.message}`
|
|
1323
|
+
: reach !== 'up' ? `: ${unreachableClause(entry.serverId, reach)}` : '';
|
|
1324
|
+
// A tune-out is in effect already: only its transport waits for the
|
|
1325
|
+
// server's next registration.
|
|
1326
|
+
const stays = now === 'tuned-out' ? 'It was tuned out meanwhile, and that stays recorded.'
|
|
1327
|
+
: `Its ${now === 'closed' ? 'close' : 'open'} stays recorded, and happens ${later}.`;
|
|
1328
|
+
return `Channel ${channel} isn't registered here any more${why}. ${stays}` +
|
|
1329
|
+
(reach === 'gone' ? ' If you need the server back, ask whoever runs this host.' : '') +
|
|
1330
|
+
(failure === undefined ? ` ${withStop(lifecycleClause(err))}` : '');
|
|
1331
|
+
}
|
|
1332
|
+
// A tune-out sends the channel's messages to the subconscious, and
|
|
1333
|
+
// channel_open refuses a tuned-out channel.
|
|
1334
|
+
if (action === 'open' ? now !== 'open' : now !== 'closed') {
|
|
1335
|
+
if (action === 'open' && now === 'tuned-out') {
|
|
1336
|
+
return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_open doesn't ` +
|
|
1337
|
+
`change that. Call tune_out with channelId ${channel} and mode "cancel" for it to resume normal attention.`;
|
|
1338
|
+
}
|
|
1339
|
+
// The later decision's own work settles the transport, and its call
|
|
1340
|
+
// says what became of it, so this says only that this call's change
|
|
1341
|
+
// doesn't stand.
|
|
1342
|
+
return `Channel ${channel}: ${supersededClause(action, now)}, so this ${action} doesn't stand. Call ${tool} again to ` +
|
|
1343
|
+
`${action} it.`;
|
|
1344
|
+
}
|
|
1345
|
+
const reach = failure ? (failure.kind === 'unreachable' ? failure.reach : undefined) : this.serverReach(entry.serverId);
|
|
1346
|
+
if (reach !== undefined && reach !== 'up') {
|
|
1347
|
+
// Reconciling opens a channel, but asks for no history.
|
|
1348
|
+
const history = action === 'open' && opts?.history;
|
|
1349
|
+
return `${sentenceStart(unreachableClause(entry.serverId, reach))}, so channel ${channel} may not be ` +
|
|
1350
|
+
`${action === 'open' ? 'open' : 'closed'} there yet. The ${action} is recorded and happens ${later}` +
|
|
1351
|
+
(history
|
|
1352
|
+
? ', but the history you asked for doesn\'t come with it: calling channel_open with backscroll once the server is ' +
|
|
1353
|
+
'connected asks for it.'
|
|
1354
|
+
: `, so there's no need to call ${tool} again.`) +
|
|
1355
|
+
(reach === 'gone' ? ' If you need the server back, ask whoever runs this host.' : '') +
|
|
1356
|
+
(action === 'open' ? ' channel_close withdraws it.' : '');
|
|
1357
|
+
}
|
|
1358
|
+
const said = lifecycleClause(err);
|
|
1359
|
+
// Other work for the channel still under way (lifecycleOutstanding,
|
|
1360
|
+
// released as each operation's last look ends) may change its state.
|
|
1361
|
+
const settled = !this.lifecycleOutstanding.has(this.lifecycleKey(entry.serverId, entry.descriptor.id));
|
|
1362
|
+
// The registry's own reading, as its checks make it (channel_open's
|
|
1363
|
+
// already-open return, the decline's refusal, delivery): a request that
|
|
1364
|
+
// failed leaves the open flag as it was, and the unconfirmed mark counts
|
|
1365
|
+
// only while the channel isn't open (runLifecycleOperation).
|
|
1366
|
+
const inEffect = current !== undefined && settled && (action === 'open'
|
|
1367
|
+
? current.open
|
|
1368
|
+
: !current.open && !this.unconfirmedChannelTargets.has(current));
|
|
1369
|
+
if (inEffect) {
|
|
1370
|
+
// What was asked already holds. Without the grant, nothing more can be
|
|
1371
|
+
// asked of the server, so calling again won't change anything.
|
|
1372
|
+
const state = `Channel ${channel} is ${action === 'open' ? 'open' : 'closed'}`;
|
|
1373
|
+
if (action === 'open' && opts?.history) {
|
|
1374
|
+
return failure?.kind === 'not-permitted'
|
|
1375
|
+
? `${state}, but the history you asked for didn't come: ${failure.message}, so calling channel_open again ` +
|
|
1376
|
+
"won't change that. If you need it, ask whoever runs this host."
|
|
1377
|
+
: withStop(`${state}, but the history you asked for didn't come: ${said}`) +
|
|
1378
|
+
' Calling channel_open again with backscroll tries once more.';
|
|
1379
|
+
}
|
|
1380
|
+
return failure?.kind === 'not-permitted'
|
|
1381
|
+
? `${state}. ${sentenceStart(failure.message)}, so it sent the server nothing.`
|
|
1382
|
+
: withStop(`${state}, though this call didn't complete: ${said}`);
|
|
1383
|
+
}
|
|
1384
|
+
switch (failure?.kind) {
|
|
1385
|
+
case 'not-permitted':
|
|
1386
|
+
// The decision was recorded before the refusal, and reconciling
|
|
1387
|
+
// applies it at a registration whose grant allows it.
|
|
1388
|
+
return `${sentenceStart(failure.message)}, so this ${action} of channel ${channel} didn't go through, and calling ${tool} ` +
|
|
1389
|
+
`again won't change that. The ${action} stays recorded, and happens ${later}. If you need it, ask whoever runs this host.`;
|
|
1390
|
+
case 'cancelled':
|
|
1391
|
+
case 'unlisted':
|
|
1392
|
+
return `Channel ${channel} wasn't ${done}: ${failure.message}. The ${action} stays recorded, and happens ${later}.`;
|
|
1393
|
+
case 'converge':
|
|
1394
|
+
return `Channel ${channel} wasn't ${done}: ${failure.message}. The ${action} stays recorded, and calling ${tool} again ` +
|
|
1395
|
+
'tries once more.';
|
|
1396
|
+
case 'substituted':
|
|
1397
|
+
// Only an open is answered for another channel.
|
|
1398
|
+
return `Channel ${channel} wasn't ${done}: ${failure.message}. The ${action} stays recorded, and this host tries it ` +
|
|
1399
|
+
`again ${later}; calling ${tool} again is likely to get the same answer. channel_close withdraws it.`;
|
|
1400
|
+
}
|
|
1401
|
+
return `${action === 'open' ? 'Opening' : 'Closing'} channel ${channel} didn't complete; the ${action} stays recorded, and this ` +
|
|
1402
|
+
`host tries it again ${later}, or now if you call ${tool} again. ${withStop(said)}`;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/** A registration's entry, or a push's (ensureChannelRegistered): nothing
|
|
1406
|
+
* about its transport is known until a receipt for its target confirms it
|
|
1407
|
+
* open or closed, or the channel's traffic shows it open, which it does
|
|
1408
|
+
* where the host doesn't direct the lifecycle, and for a push's entry
|
|
1409
|
+
* decided open or tuned out that no update has given a target
|
|
1410
|
+
* (handleIncoming). */
|
|
1411
|
+
private registerEntry(key: string, serverId: string, descriptor: ChannelDescriptor): ChannelEntry {
|
|
835
1412
|
const entry: ChannelEntry = { serverId, descriptor, open: false };
|
|
836
1413
|
this.channels.set(key, entry);
|
|
837
1414
|
this.unconfirmedChannelTargets.add(entry);
|
|
1415
|
+
return entry;
|
|
838
1416
|
}
|
|
839
1417
|
|
|
840
1418
|
private hasPendingLifecycle(entry: ChannelEntry): boolean {
|
|
@@ -888,6 +1466,7 @@ export class ChannelRegistry {
|
|
|
888
1466
|
this.homeChannelResolver = options?.homeChannelResolver;
|
|
889
1467
|
this.activeChannelResolver = options?.activeChannelResolver;
|
|
890
1468
|
this.takesGlobalFallback = options?.takesGlobalFallback;
|
|
1469
|
+
this.serverConnecting = options?.serverConnecting;
|
|
891
1470
|
this.store = options?.store;
|
|
892
1471
|
this.initializeLifecycleStore();
|
|
893
1472
|
this.initializeLabelHistoryStore();
|
|
@@ -1007,13 +1586,16 @@ export class ChannelRegistry {
|
|
|
1007
1586
|
// channel decided open (or tuned out) is opened at its new target.
|
|
1008
1587
|
if (!sameChannelTarget(existing.descriptor, channel)) {
|
|
1009
1588
|
this.invalidateTransportConfirmation(existing);
|
|
1589
|
+
this.untargetedPushEntries.delete(existing);
|
|
1010
1590
|
// An operation queued or running for this channel reads its target
|
|
1011
1591
|
// again before it confirms anything, and after a failed RPC, so it
|
|
1012
|
-
// opens the new one
|
|
1013
|
-
//
|
|
1592
|
+
// opens the new one, unless it was queued before its server's
|
|
1593
|
+
// registrations were last removed: that one cancels when it would
|
|
1594
|
+
// run. So a channel decided open (or tuned out) is reconciled here
|
|
1595
|
+
// unless work queued since then is outstanding.
|
|
1014
1596
|
const desired = this.getDesiredState(serverId, channel.id);
|
|
1015
1597
|
if ((desired === 'open' || desired === 'tuned-out') &&
|
|
1016
|
-
!this.
|
|
1598
|
+
!this.outstandingWorkCarriesOut(serverId, this.lifecycleKey(serverId, channel.id))) {
|
|
1017
1599
|
retargeted.push(channel);
|
|
1018
1600
|
}
|
|
1019
1601
|
}
|
|
@@ -1112,6 +1694,9 @@ export class ChannelRegistry {
|
|
|
1112
1694
|
responder?: Responder,
|
|
1113
1695
|
): Promise<void> {
|
|
1114
1696
|
const results: ChannelIncomingMessageResult[] = [];
|
|
1697
|
+
// The batch came on the transport open now: a close after this point
|
|
1698
|
+
// (transportClosed) leaves its traffic nothing to confirm.
|
|
1699
|
+
const closesAtArrival = this.transportCloses.get(serverId) ?? 0;
|
|
1115
1700
|
|
|
1116
1701
|
for (const message of params.messages) {
|
|
1117
1702
|
if (!message || typeof message.channelId !== 'string' || !message.channelId
|
|
@@ -1139,17 +1724,6 @@ export class ChannelRegistry {
|
|
|
1139
1724
|
// inline base64) is allocation and parsing work done for a message
|
|
1140
1725
|
// that must be rejected (Sol, PR #79 re-review blocker 2).
|
|
1141
1726
|
|
|
1142
|
-
// Lazy-register the channel if we've never seen it. A channel can deliver
|
|
1143
|
-
// an incoming message before its channels/register (boot enumeration) or
|
|
1144
|
-
// channels/changed (post-boot create / View-permission grant) round-trip
|
|
1145
|
-
// lands — or the registration event can be missed entirely (e.g. the bot
|
|
1146
|
-
// gains visibility in a way that fires neither `channelCreate` nor a
|
|
1147
|
-
// View-permission transition). Without a registry entry, routeSpeech()
|
|
1148
|
-
// can't resolve this channel as an outbound locus and the agent's reply
|
|
1149
|
-
// is silently dropped, even though this very message proves the channel
|
|
1150
|
-
// is reachable. The inbound message carries enough to make it publishable,
|
|
1151
|
-
// so register it here; a later authoritative channels/register or
|
|
1152
|
-
// channels/changed will overwrite this descriptor with the richer one.
|
|
1153
1727
|
// §14.5: channels/incoming is validated against the ACTUALLY
|
|
1154
1728
|
// REGISTERED channel. An unknown claimed channelId is rejected
|
|
1155
1729
|
// per-message with an itemized result — never minted by its first
|
|
@@ -1157,8 +1731,8 @@ export class ChannelRegistry {
|
|
|
1157
1731
|
// self-attest a channel identity without ever passing
|
|
1158
1732
|
// channels/register authorization). A server whose channel genuinely
|
|
1159
1733
|
// exists registers it first; discord's DM case goes through
|
|
1160
|
-
// ensureChannelRegistered on push/event, which creates a
|
|
1161
|
-
//
|
|
1734
|
+
// ensureChannelRegistered on push/event, which creates a routable
|
|
1735
|
+
// entry that isn't open, rather than an open self-attested one.
|
|
1162
1736
|
const incomingKey = `${serverId}:${message.channelId}`;
|
|
1163
1737
|
if (!this.channels.has(incomingKey)) {
|
|
1164
1738
|
// Loud, coalesced or not, like every other refusal of a message, but
|
|
@@ -1235,29 +1809,47 @@ export class ChannelRegistry {
|
|
|
1235
1809
|
// A coalesced item is "accepted" only once the coalescer admits it, so
|
|
1236
1810
|
// for those this runs after the hook (below).
|
|
1237
1811
|
const incomingEntry = this.channels.get(incomingKey)!;
|
|
1238
|
-
const markAccepted = () => {
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
//
|
|
1243
|
-
// the
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
//
|
|
1812
|
+
const markAccepted = (firstView = false) => {
|
|
1813
|
+
// Non-conversational markers (a reaction added or removed, an edit, a
|
|
1814
|
+
// deletion) are ABOUT a message, not one the agent replies to: they
|
|
1815
|
+
// must not retarget the default publish locus or become
|
|
1816
|
+
// `context.incoming`'s reply anchor. An edit the coalescer delivered
|
|
1817
|
+
// as the agent's first view counts as the message it replaced.
|
|
1818
|
+
if (!isMarkerTagged(message.tags, firstView)) {
|
|
1819
|
+
this.defaultPublishChannel = message.channelId;
|
|
1820
|
+
this.defaultPublishMessageId = message.messageId;
|
|
1821
|
+
this.defaultPublishThreadId = message.threadId;
|
|
1822
|
+
}
|
|
1823
|
+
// Where the host directs this server's lifecycle, traffic shows the
|
|
1824
|
+
// transport open only for a channel still decided open or tuned out,
|
|
1825
|
+
// and, but for one exception, only for a confirmed target: after a
|
|
1826
|
+
// retarget, traffic for this id may still come from the old
|
|
1827
|
+
// subscription, and a close receipt that landed first (or during the
|
|
1828
|
+
// coalescer's await) stands. The exception is an entry made from a
|
|
1829
|
+
// push that no update has given a target (untargetedPushEntries):
|
|
1830
|
+
// there's no earlier target its traffic could come from, so for the
|
|
1831
|
+
// receipt rule's own reason its traffic shows the transport open. It
|
|
1832
|
+
// stops being one when a registration replaces it (registerEntry) or
|
|
1833
|
+
// a channels/changed update gives it a target (handleChanged's
|
|
1834
|
+
// retarget), and the receipt rule holds from then. Where the host
|
|
1835
|
+
// doesn't direct the lifecycle, the server opens its own channels and
|
|
1836
|
+
// its traffic is the only evidence the host gets, so it marks the
|
|
1250
1837
|
// channel open, as it always has. The entry is the one the message
|
|
1251
1838
|
// was accepted for: one a re-registration put in its place meanwhile
|
|
1252
|
-
// waits for its own evidence.
|
|
1253
|
-
//
|
|
1839
|
+
// waits for its own evidence. Traffic from a transport that has closed
|
|
1840
|
+
// since its batch arrived (here after the coalescer's await, or behind
|
|
1841
|
+
// a sibling's) is no evidence either: the close withdrew what that
|
|
1842
|
+
// transport confirmed, and its connection's grant is empty until the
|
|
1843
|
+
// next one's. Durable desired state still changes exclusively through
|
|
1844
|
+
// lifecycle operations. A marker is evidence like any other message.
|
|
1845
|
+
if ((this.transportCloses.get(serverId) ?? 0) !== closesAtArrival) return;
|
|
1254
1846
|
if (this.directsLifecycle(serverId)) {
|
|
1255
|
-
if (this.unconfirmedChannelTargets.has(incomingEntry)) return;
|
|
1847
|
+
if (this.unconfirmedChannelTargets.has(incomingEntry) && !this.untargetedPushEntries.has(incomingEntry)) return;
|
|
1256
1848
|
const desired = this.getDesiredState(serverId, message.channelId);
|
|
1257
1849
|
if (desired !== 'open' && desired !== 'tuned-out') return;
|
|
1258
|
-
} else {
|
|
1259
|
-
this.unconfirmedChannelTargets.delete(incomingEntry);
|
|
1260
1850
|
}
|
|
1851
|
+
this.unconfirmedChannelTargets.delete(incomingEntry);
|
|
1852
|
+
this.failedCloseTargets.delete(incomingEntry);
|
|
1261
1853
|
incomingEntry.open = true;
|
|
1262
1854
|
};
|
|
1263
1855
|
if (!coalesced) markAccepted();
|
|
@@ -1309,8 +1901,8 @@ export class ChannelRegistry {
|
|
|
1309
1901
|
// the coalescer, which replaces, appends or withdraws. Malformed
|
|
1310
1902
|
// `coalesce` is a per-message failure; siblings are unaffected.
|
|
1311
1903
|
try {
|
|
1312
|
-
const result = await this.handleCoalescedIncoming!(serverId, message, event);
|
|
1313
|
-
if (result.accepted) markAccepted();
|
|
1904
|
+
const { firstView, ...result } = await this.handleCoalescedIncoming!(serverId, message, event);
|
|
1905
|
+
if (result.accepted) markAccepted(firstView === true);
|
|
1314
1906
|
results.push(result);
|
|
1315
1907
|
} catch (error) {
|
|
1316
1908
|
const err = error as Error & { code?: number; field?: string };
|
|
@@ -1360,15 +1952,17 @@ export class ChannelRegistry {
|
|
|
1360
1952
|
* locus. A push event from a closed channel must never mutate lifecycle
|
|
1361
1953
|
* state: direct-address events are intentionally usable without subscribing.
|
|
1362
1954
|
*
|
|
1363
|
-
*
|
|
1364
|
-
*
|
|
1365
|
-
*
|
|
1366
|
-
*
|
|
1367
|
-
*
|
|
1368
|
-
*
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1371
|
-
*
|
|
1955
|
+
* For channels that only ever arrive as push/events rather than
|
|
1956
|
+
* channels/incoming — the motivating case is Discord DMs, which
|
|
1957
|
+
* discord-mcpl forwards via push/event with the channel closed
|
|
1958
|
+
* (`channelIsOpen:false`). Such a channel is never registered, so
|
|
1959
|
+
* routeSpeech() can't resolve it and the agent's reply is silently dropped
|
|
1960
|
+
* even though the inbound message proves the channel is reachable (item-3
|
|
1961
|
+
* redux, DM sub-case). Its entry starts with its transport unknown, as a
|
|
1962
|
+
* registration's does. Its traffic shows it open, for a channel decided
|
|
1963
|
+
* open or tuned out, until an update gives it a target (handleIncoming).
|
|
1964
|
+
* Idempotent: a later authoritative channels/register or channels/changed
|
|
1965
|
+
* overwrites this descriptor with the richer one.
|
|
1372
1966
|
*/
|
|
1373
1967
|
ensureChannelRegistered(
|
|
1374
1968
|
serverId: string,
|
|
@@ -1382,7 +1976,7 @@ export class ChannelRegistry {
|
|
|
1382
1976
|
// arrived with no label/recipient info gets a usable name the next
|
|
1383
1977
|
// time a message reveals it (labels must show people, not ids).
|
|
1384
1978
|
if (
|
|
1385
|
-
(existing
|
|
1979
|
+
pushPlaceholder(existing) &&
|
|
1386
1980
|
label && existing.descriptor.label === channelId
|
|
1387
1981
|
) {
|
|
1388
1982
|
const metadata = extraMetadata
|
|
@@ -1413,18 +2007,19 @@ export class ChannelRegistry {
|
|
|
1413
2007
|
// label to record. A real label is recorded before the entry exists:
|
|
1414
2008
|
// an append that throws registers nothing, so the next push tries again.
|
|
1415
2009
|
if (label) this.appendLabelSighting(channelId, label, extractDmMeta(extraMetadata));
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
2010
|
+
// Its transport is as unknown as a registration's (registerEntry). After
|
|
2011
|
+
// a restart, a push for a channel no registration has comes here, and
|
|
2012
|
+
// the server may still hold a channel an earlier process opened. Counting
|
|
2013
|
+
// it confirmed closed would answer a close or a decline as already done
|
|
2014
|
+
// and send nothing, even where the server still delivers.
|
|
2015
|
+
const entry = this.registerEntry(`${serverId}:${channelId}`, serverId, {
|
|
2016
|
+
id: channelId,
|
|
2017
|
+
type: serverId,
|
|
2018
|
+
label: label ?? channelId,
|
|
2019
|
+
direction: 'bidirectional',
|
|
2020
|
+
metadata: { lazyRegistered: true, ...(extraMetadata ?? {}) },
|
|
1427
2021
|
});
|
|
2022
|
+
this.untargetedPushEntries.add(entry);
|
|
1428
2023
|
this.emitTraceFn({
|
|
1429
2024
|
type: 'mcpl:channel-lazy-registered',
|
|
1430
2025
|
serverId,
|
|
@@ -1533,7 +2128,10 @@ export class ChannelRegistry {
|
|
|
1533
2128
|
// ==========================================================================
|
|
1534
2129
|
|
|
1535
2130
|
/**
|
|
1536
|
-
* Get the default publish channel ID
|
|
2131
|
+
* Get the default publish channel ID: the channel of the most recent
|
|
2132
|
+
* incoming message that isn't a reaction, reaction removal, edit or
|
|
2133
|
+
* deletion marker, apart from an edit the coalescer delivers on acceptance
|
|
2134
|
+
* as the agent's first view.
|
|
1537
2135
|
*/
|
|
1538
2136
|
getDefaultPublishChannel(): string | null {
|
|
1539
2137
|
return this.defaultPublishChannel;
|
|
@@ -1552,7 +2150,7 @@ export class ChannelRegistry {
|
|
|
1552
2150
|
*/
|
|
1553
2151
|
isDeclaredChannel(serverId: string, channelId: string): boolean {
|
|
1554
2152
|
const entry = this.channels.get(`${serverId}:${channelId}`);
|
|
1555
|
-
return !!entry && !(entry
|
|
2153
|
+
return !!entry && !pushPlaceholder(entry);
|
|
1556
2154
|
}
|
|
1557
2155
|
|
|
1558
2156
|
getDescriptor(channelId: string): ChannelDescriptor | undefined {
|
|
@@ -1566,8 +2164,15 @@ export class ChannelRegistry {
|
|
|
1566
2164
|
return typeof label === 'string' && label.length > 0 ? label : undefined;
|
|
1567
2165
|
}
|
|
1568
2166
|
|
|
1569
|
-
|
|
1570
|
-
|
|
2167
|
+
/** Whether `channelId`'s transport is open: on `serverId` where the caller
|
|
2168
|
+
* knows the channel's server, and otherwise on the first server found to
|
|
2169
|
+
* register that id (findChannelEntry). The map's key joins the two ids
|
|
2170
|
+
* with a colon, so another server's entry can sit under a key: it has to
|
|
2171
|
+
* be this server's (as in streamingServerAt). */
|
|
2172
|
+
isChannelOpen(channelId: string, serverId?: string): boolean {
|
|
2173
|
+
if (serverId === undefined) return this.findChannelEntry(channelId)?.open === true;
|
|
2174
|
+
const entry = this.channels.get(`${serverId}:${channelId}`);
|
|
2175
|
+
return entry?.serverId === serverId && entry.open === true;
|
|
1571
2176
|
}
|
|
1572
2177
|
|
|
1573
2178
|
getDesiredState(serverId: string, channelId: string): DesiredChannelState | undefined {
|
|
@@ -1659,7 +2264,13 @@ export class ChannelRegistry {
|
|
|
1659
2264
|
return this.handleToolSkipReply(input as { reason?: string; wake_in_seconds?: unknown });
|
|
1660
2265
|
|
|
1661
2266
|
default:
|
|
1662
|
-
|
|
2267
|
+
// Not every agent is given every channel tool, so the refusal points
|
|
2268
|
+
// to the ones this agent has rather than listing them all.
|
|
2269
|
+
return {
|
|
2270
|
+
success: false,
|
|
2271
|
+
error: `There's no channel tool "${toolName}", so nothing ran. Use one of the channel tools you were given.`,
|
|
2272
|
+
isError: true,
|
|
2273
|
+
};
|
|
1663
2274
|
}
|
|
1664
2275
|
}
|
|
1665
2276
|
|
|
@@ -1676,8 +2287,9 @@ export class ChannelRegistry {
|
|
|
1676
2287
|
const openChannels = this.getOpenChannels();
|
|
1677
2288
|
|
|
1678
2289
|
// Resolve the outbound locus the SAME way routeSpeech does, so the agent is
|
|
1679
|
-
// told the channel its speech will actually land in
|
|
1680
|
-
//
|
|
2290
|
+
// told the channel its speech will actually land in (none, when its server
|
|
2291
|
+
// declared it inbound-only, since speech isn't posted there): a conversation
|
|
2292
|
+
// fork's home channel, else this turn's channel (single trunk agent — item-3
|
|
1681
2293
|
// redux: its locus, else its triggering channel), else the global default
|
|
1682
2294
|
// (heartbeats). Without this, a fork
|
|
1683
2295
|
// was advertised the global locus but published somewhere else; and a trunk
|
|
@@ -1698,7 +2310,10 @@ export class ChannelRegistry {
|
|
|
1698
2310
|
|
|
1699
2311
|
const context: ChannelContext = {};
|
|
1700
2312
|
|
|
1701
|
-
// Incoming: the most
|
|
2313
|
+
// Incoming: the most recent inbound message that isn't a reaction,
|
|
2314
|
+
// reaction removal, edit or deletion marker, apart from an edit the
|
|
2315
|
+
// coalescer delivers on acceptance as the agent's first view (what the
|
|
2316
|
+
// agent is replying to).
|
|
1702
2317
|
// Left process-global — per-channel inbound tracking (the right messageId for
|
|
1703
2318
|
// a fork's own channel) is a separate concern from the outbound routing fix.
|
|
1704
2319
|
if (fallback && this.defaultPublishMessageId) {
|
|
@@ -1709,8 +2324,10 @@ export class ChannelRegistry {
|
|
|
1709
2324
|
};
|
|
1710
2325
|
}
|
|
1711
2326
|
|
|
1712
|
-
// Default outgoing: the resolved outbound locus (home channel for forks)
|
|
1713
|
-
|
|
2327
|
+
// Default outgoing: the resolved outbound locus (home channel for forks),
|
|
2328
|
+
// unless its server declared it inbound-only: routeSpeech publishes nothing
|
|
2329
|
+
// there, so it isn't where the agent's speech goes.
|
|
2330
|
+
if (outgoing && !this.inboundOnlyServer(outgoing)) {
|
|
1714
2331
|
context.defaultOutgoing = {
|
|
1715
2332
|
channelId: outgoing,
|
|
1716
2333
|
};
|
|
@@ -1735,7 +2352,7 @@ export class ChannelRegistry {
|
|
|
1735
2352
|
*/
|
|
1736
2353
|
removeServer(serverId: string): void {
|
|
1737
2354
|
// Every lifecycle operation queued for this server is cancelled when it
|
|
1738
|
-
// would run (
|
|
2355
|
+
// would run (a ChannelLifecycleError of kind `cancelled`), so none acts on a closing
|
|
1739
2356
|
// connection or on a later re-registration's entries.
|
|
1740
2357
|
this.serverRemovals.set(serverId, (this.serverRemovals.get(serverId) ?? 0) + 1);
|
|
1741
2358
|
for (const [key, entry] of this.channels) {
|
|
@@ -1751,6 +2368,42 @@ export class ChannelRegistry {
|
|
|
1751
2368
|
// Desired state and migration markers deliberately survive disconnects.
|
|
1752
2369
|
}
|
|
1753
2370
|
|
|
2371
|
+
/**
|
|
2372
|
+
* The server's transport closed. Whatever it confirmed about a channel,
|
|
2373
|
+
* open or closed, held for that transport only, so each of the server's
|
|
2374
|
+
* channels reads as a registration's does: not open, and not confirmed
|
|
2375
|
+
* closed. Where the host directs the server's lifecycle, a receipt
|
|
2376
|
+
* confirms it again: a `channel_open` asks the server, and delivery opens
|
|
2377
|
+
* before it publishes (an untargeted push entry's traffic confirms it, as
|
|
2378
|
+
* before). Where the host doesn't, its traffic does, as after a
|
|
2379
|
+
* registration. The entries, their desired state, labels and tune-out
|
|
2380
|
+
* state stay, since the server is expected back (`removeServer` is for one
|
|
2381
|
+
* that isn't).
|
|
2382
|
+
*
|
|
2383
|
+
* What was under way here at the close confirms nothing afterwards
|
|
2384
|
+
* (transportCloses). Traffic that arrived before the close and is admitted
|
|
2385
|
+
* after it, behind the coalescer's await, marks nothing open
|
|
2386
|
+
* (handleIncoming). A lifecycle request sent before the close is answered
|
|
2387
|
+
* for an older transport: its failure withdraws the open flag, and its
|
|
2388
|
+
* receipt confirms nothing, and the operation tries again, which on the
|
|
2389
|
+
* closed connection is refused as unreachable, so the decision waits for
|
|
2390
|
+
* the next registration (runLifecycleOperation's sameTarget). What the
|
|
2391
|
+
* connection holds across the boundary is another matter: it reaches
|
|
2392
|
+
* handleIncoming after the next transport's grant, as that transport's
|
|
2393
|
+
* traffic (resetPolicyForTransportBoundary), as it does after a
|
|
2394
|
+
* registration.
|
|
2395
|
+
*
|
|
2396
|
+
* The framework calls this first in its handler for the connection's
|
|
2397
|
+
* `close`, so a registration on the next transport, whose entries are new,
|
|
2398
|
+
* can't come first and lose its confirmations to it.
|
|
2399
|
+
*/
|
|
2400
|
+
transportClosed(serverId: string): void {
|
|
2401
|
+
this.transportCloses.set(serverId, (this.transportCloses.get(serverId) ?? 0) + 1);
|
|
2402
|
+
for (const entry of this.channels.values()) {
|
|
2403
|
+
if (entry.serverId === serverId) this.invalidateTransportConfirmation(entry);
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
|
|
1754
2407
|
/**
|
|
1755
2408
|
* Stop all typing intervals and clear all channel registrations.
|
|
1756
2409
|
*/
|
|
@@ -1790,20 +2443,11 @@ export class ChannelRegistry {
|
|
|
1790
2443
|
if (!item || typeof item !== 'object') continue;
|
|
1791
2444
|
const event = item as Partial<ChannelLifecycleEvent>;
|
|
1792
2445
|
if (typeof event.serverId !== 'string') continue;
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
typeof event.channelId === 'string' &&
|
|
1796
|
-
(event.desired === 'open' || event.desired === 'closed' ||
|
|
1797
|
-
(event.desired === 'tuned-out' && event.tuneOut))
|
|
1798
|
-
) {
|
|
2446
|
+
const decision = desiredStateRecord(item);
|
|
2447
|
+
if (decision) {
|
|
1799
2448
|
this.desiredStates.set(
|
|
1800
|
-
this.lifecycleKey(
|
|
1801
|
-
{
|
|
1802
|
-
state: event.desired,
|
|
1803
|
-
source: typeof event.source === "string" ? event.source : "unknown",
|
|
1804
|
-
tuneOut: event.desired === 'tuned-out' ? event.tuneOut : undefined,
|
|
1805
|
-
wakeCount: 0,
|
|
1806
|
-
},
|
|
2449
|
+
this.lifecycleKey(decision.serverId, decision.channelId),
|
|
2450
|
+
{ state: decision.state, source: decision.source, tuneOut: decision.tuneOut, wakeCount: 0 },
|
|
1807
2451
|
);
|
|
1808
2452
|
} else if (
|
|
1809
2453
|
event.kind === 'tune-out-wake' &&
|
|
@@ -1827,7 +2471,9 @@ export class ChannelRegistry {
|
|
|
1827
2471
|
/**
|
|
1828
2472
|
* Each writer appends its record here before it changes memory for it, so
|
|
1829
2473
|
* an append that throws, as on a full disk, fails the operation with that
|
|
1830
|
-
* change unmade, and memory and the log still agree. A
|
|
2474
|
+
* change unmade, and memory and the log still agree. A record that changes
|
|
2475
|
+
* no memory, as a decline's of the invitation it declined, is its
|
|
2476
|
+
* caller's to handle (handleToolDecline). A retry then records
|
|
1831
2477
|
* the change rather than finding it in memory already, where
|
|
1832
2478
|
* setDesiredState's same-state return would record nothing, and a restart
|
|
1833
2479
|
* replays what memory held. appendLabelSighting keeps the same order for
|
|
@@ -2175,7 +2821,20 @@ export class ChannelRegistry {
|
|
|
2175
2821
|
source: string,
|
|
2176
2822
|
): void {
|
|
2177
2823
|
const key = this.lifecycleKey(serverId, channelId);
|
|
2178
|
-
|
|
2824
|
+
const current = this.desiredStates.get(key);
|
|
2825
|
+
// A decision that records the state already on record changes nothing,
|
|
2826
|
+
// so it writes nothing, unless it's the agent's own (channel_open,
|
|
2827
|
+
// channel_close or channel_decline) and the record isn't. The record
|
|
2828
|
+
// then says the agent decided, which is all handleToolClose's
|
|
2829
|
+
// explicit-open guard and ensureInitialDesiredState's lift of a default
|
|
2830
|
+
// ask of it. openChannelNow depends on it too: a failed open by speech
|
|
2831
|
+
// or a reply tells a decision made during its round-trip by the record
|
|
2832
|
+
// no longer being the object it wrote, and the agent's channel_open,
|
|
2833
|
+
// the only decision of the same state that can be made then, gets a new
|
|
2834
|
+
// record here.
|
|
2835
|
+
if (current?.state === desired && !(AGENT_DECISION_SOURCES.has(source) && !AGENT_DECISION_SOURCES.has(current.source))) {
|
|
2836
|
+
return;
|
|
2837
|
+
}
|
|
2179
2838
|
this.appendLifecycleEvent({
|
|
2180
2839
|
kind: 'desired-state',
|
|
2181
2840
|
serverId,
|
|
@@ -2195,7 +2854,9 @@ export class ChannelRegistry {
|
|
|
2195
2854
|
* Enter (or re-enter with fresh params) the tuned-out state for a channel.
|
|
2196
2855
|
* Re-entering under a new epochId replaces the active epoch; the previous
|
|
2197
2856
|
* epoch's stamped messages stay excluded (stamps are permanent) and its
|
|
2198
|
-
* wake count is superseded.
|
|
2857
|
+
* wake count is superseded. Its transport is kept open: tune_out opens a
|
|
2858
|
+
* closed one (openForTuneOut), and reconcile opens it at the channel's
|
|
2859
|
+
* registration.
|
|
2199
2860
|
*/
|
|
2200
2861
|
enterTuneOut(
|
|
2201
2862
|
serverId: string,
|
|
@@ -2221,6 +2882,182 @@ export class ChannelRegistry {
|
|
|
2221
2882
|
});
|
|
2222
2883
|
}
|
|
2223
2884
|
|
|
2885
|
+
/**
|
|
2886
|
+
* Make a tuned-out channel's transport match its tune-out, which keeps it
|
|
2887
|
+
* open so its traffic reaches the subconscious (TuneOutParams). The
|
|
2888
|
+
* tune-out is recorded first (enterTuneOut), and this writes no decision,
|
|
2889
|
+
* so the tune-out stands whatever becomes of the open. Lifecycle work
|
|
2890
|
+
* queued for the channel goes first, until none is left, since each
|
|
2891
|
+
* operation reads the decision when it runs and so opens a channel tuned
|
|
2892
|
+
* out meanwhile; its own failure, such as a close the server refused,
|
|
2893
|
+
* isn't this open's. Then the state is read. Where this host directs the
|
|
2894
|
+
* lifecycle and the transport isn't confirmed open, the channel's
|
|
2895
|
+
* lifecycle queue opens it once (applyDesiredChannelState reads a
|
|
2896
|
+
* tune-out as open), and the answer waits for the work queued after it
|
|
2897
|
+
* too, such as a registration that came back, and comes from the state
|
|
2898
|
+
* that work leaves. Returns nothing where the transport is open, or, for
|
|
2899
|
+
* tune_out's answer, why the channel's messages don't or may not reach the
|
|
2900
|
+
* subconscious yet and when they will, or that the tune-out isn't in
|
|
2901
|
+
* effect any more. A call while this tune-out's open is in flight, as a
|
|
2902
|
+
* repeated tune_out makes, gets that open's answer rather than send
|
|
2903
|
+
* another.
|
|
2904
|
+
*/
|
|
2905
|
+
async openForTuneOut(serverId: string, channelId: string): Promise<string | undefined> {
|
|
2906
|
+
// The tune-out this answer is for, by its epoch: one that ended, or that
|
|
2907
|
+
// a later decision or a later tune-out replaced, isn't this one.
|
|
2908
|
+
const epochId = this.getTuneOutState(serverId, channelId)?.params.epochId;
|
|
2909
|
+
const key = `${this.lifecycleKey(serverId, channelId)}\u0000${epochId ?? ''}`;
|
|
2910
|
+
let opening = this.tuneOutOpens.get(key);
|
|
2911
|
+
if (!opening) {
|
|
2912
|
+
const started = this.openTuneOutTransport(serverId, channelId, epochId);
|
|
2913
|
+
this.tuneOutOpens.set(key, started);
|
|
2914
|
+
const release = () => {
|
|
2915
|
+
if (this.tuneOutOpens.get(key) === started) this.tuneOutOpens.delete(key);
|
|
2916
|
+
};
|
|
2917
|
+
void started.then(release, release);
|
|
2918
|
+
opening = started;
|
|
2919
|
+
}
|
|
2920
|
+
return opening;
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
/**
|
|
2924
|
+
* Whether a tune_out repeated now has nothing to wait for and finds its
|
|
2925
|
+
* channel's transport open, as the tune-out reads it: no lifecycle work
|
|
2926
|
+
* queued for the channel, since the tune-out's own call may still be
|
|
2927
|
+
* waiting for the work queued after its open; its server up; and an open
|
|
2928
|
+
* flag the tune-out takes as open (tuneOutReadsOpen). Where it doesn't,
|
|
2929
|
+
* the repeat goes through openForTuneOut, which joins this tune-out's open
|
|
2930
|
+
* in flight, or waits for the queued work and, where this host can open
|
|
2931
|
+
* the channel, tries the open again.
|
|
2932
|
+
*/
|
|
2933
|
+
tuneOutTransportOpen(serverId: string, channelId: string): boolean {
|
|
2934
|
+
return this.getTuneOutState(serverId, channelId) !== null &&
|
|
2935
|
+
!this.channelLifecycleTails.has(this.lifecycleKey(serverId, channelId)) &&
|
|
2936
|
+
this.serverReach(serverId) === 'up' &&
|
|
2937
|
+
this.tuneOutReadsOpen(this.channels.get(`${serverId}:${channelId}`));
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
/**
|
|
2941
|
+
* Whether a tune-out takes an entry's transport as open: its open flag,
|
|
2942
|
+
* unless a close for it failed since it was confirmed (failedCloseTargets),
|
|
2943
|
+
* since that close may have taken effect. The other readers take any flag
|
|
2944
|
+
* a failed request left as it was (runLifecycleOperation), since a send
|
|
2945
|
+
* through it fails where it can be seen, and channel_open sends an open
|
|
2946
|
+
* after a failed close anyway, its decision having been closed. A
|
|
2947
|
+
* tune-out's success says traffic will arrive, nothing would show it
|
|
2948
|
+
* doesn't, and the decision it replaced isn't known here. An open that
|
|
2949
|
+
* failed, as a redundant one queued behind a confirming one may, can't
|
|
2950
|
+
* have closed the channel: one for the current target leaves the flag as
|
|
2951
|
+
* it was, so an open flag stays open (one for a target no longer current
|
|
2952
|
+
* withdraws it).
|
|
2953
|
+
*/
|
|
2954
|
+
private tuneOutReadsOpen(entry: ChannelEntry | undefined): boolean {
|
|
2955
|
+
// A flag is set open only where both marks are cleared, so a failed
|
|
2956
|
+
// close on an open flag is one since its confirmation.
|
|
2957
|
+
return entry !== undefined && entry.open && !this.failedCloseTargets.has(entry);
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
private async openTuneOutTransport(
|
|
2961
|
+
serverId: string,
|
|
2962
|
+
channelId: string,
|
|
2963
|
+
epochId: string | undefined,
|
|
2964
|
+
): Promise<string | undefined> {
|
|
2965
|
+
const channelKey = `${serverId}:${channelId}`;
|
|
2966
|
+
const lifecycleKey = this.lifecycleKey(serverId, channelId);
|
|
2967
|
+
const inEffect = () =>
|
|
2968
|
+
epochId !== undefined && this.getTuneOutState(serverId, channelId)?.params.epochId === epochId;
|
|
2969
|
+
// "May not": a failed request leaves its outcome unknown, and an open
|
|
2970
|
+
// that took effect would have its traffic diverted. `again` where calling
|
|
2971
|
+
// tune_out again tries the open now: the channel is registered here and
|
|
2972
|
+
// its server up.
|
|
2973
|
+
const notYet = (later: string, why: string, again = false): string => withStop(
|
|
2974
|
+
`The tune-out is recorded, but its messages may not reach the subconscious until this host opens the channel, ` +
|
|
2975
|
+
`which it does ${later}${again ? ', or now if you call tune_out again' : ''}; ${why}`,
|
|
2976
|
+
);
|
|
2977
|
+
const nextRegistration = `when the "${serverId}" server next registers this channel, if this host may open and ` +
|
|
2978
|
+
'close its channels then';
|
|
2979
|
+
// The work queued for the channel as it stands: its tail, waited for,
|
|
2980
|
+
// and its failure returned, if it failed. Each wait below takes it until
|
|
2981
|
+
// no work is left, as a delivery's wait does (waitForOpenChannel), so
|
|
2982
|
+
// work queued while earlier work ran, such as a registration that came
|
|
2983
|
+
// back, goes first too.
|
|
2984
|
+
let awaited: Promise<void> | undefined;
|
|
2985
|
+
const settle = async (): Promise<unknown> => {
|
|
2986
|
+
const pending = this.channelLifecycleTails.get(lifecycleKey);
|
|
2987
|
+
if (!pending) return undefined;
|
|
2988
|
+
awaited = pending;
|
|
2989
|
+
try {
|
|
2990
|
+
await pending;
|
|
2991
|
+
return undefined;
|
|
2992
|
+
} catch (error) {
|
|
2993
|
+
return error;
|
|
2994
|
+
}
|
|
2995
|
+
};
|
|
2996
|
+
// What the state answers now, or null where this host is to open the
|
|
2997
|
+
// transport itself. `missing` answers for a channel that isn't
|
|
2998
|
+
// registered here, where this host directs the lifecycle.
|
|
2999
|
+
const answered = (entry: ChannelEntry | undefined, missing: string): string | undefined | null => {
|
|
3000
|
+
if (!inEffect()) return TUNE_OUT_REPLACED;
|
|
3001
|
+
// A connection that dropped keeps its entries' open flags, and whether
|
|
3002
|
+
// this host or the server opens the channel isn't known until it's
|
|
3003
|
+
// back, since a reconnect empties the grant until the new one is
|
|
3004
|
+
// established.
|
|
3005
|
+
const reach = this.serverReach(serverId);
|
|
3006
|
+
if (reach !== 'up') {
|
|
3007
|
+
return withStop(
|
|
3008
|
+
`The tune-out is recorded, but its messages don't reach the subconscious until the "${serverId}" server is ` +
|
|
3009
|
+
`connected and the channel is open; it can't be opened now: ${unreachableClause(serverId, reach)}` +
|
|
3010
|
+
(reach === 'gone' ? unreachableRemedy(reach) : ''),
|
|
3011
|
+
);
|
|
3012
|
+
}
|
|
3013
|
+
const transportOpen = this.tuneOutReadsOpen(entry);
|
|
3014
|
+
if (!this.directsLifecycle(serverId)) {
|
|
3015
|
+
return transportOpen ? undefined
|
|
3016
|
+
: `The tune-out is recorded, but ${this.lifecycleRefusal(serverId).message}, so its messages reach the ` +
|
|
3017
|
+
'subconscious only as that server sends them, and calling tune_out again won\'t change that. If you need ' +
|
|
3018
|
+
'this host to open it, ask whoever runs this host.';
|
|
3019
|
+
}
|
|
3020
|
+
if (!entry) return missing;
|
|
3021
|
+
// A flag a close that may have taken effect left gets an open.
|
|
3022
|
+
return transportOpen ? undefined : null;
|
|
3023
|
+
};
|
|
3024
|
+
// Whether work is queued that settle hasn't waited for. It's looked at
|
|
3025
|
+
// in the turn that reads the state and queues the open, or answers, so
|
|
3026
|
+
// work queued after settle's look is waited for too. A settled tail
|
|
3027
|
+
// releases its own key before this runs, since its cleanup was
|
|
3028
|
+
// registered first (applyDesiredChannelState); one still standing isn't
|
|
3029
|
+
// waited for again, so the wait can't spin on it should that order ever
|
|
3030
|
+
// change, as waitForOpenChannel's guard keeps its own from doing.
|
|
3031
|
+
const queuedSince = (): boolean => {
|
|
3032
|
+
const tail = this.channelLifecycleTails.get(lifecycleKey);
|
|
3033
|
+
return tail !== undefined && tail !== awaited;
|
|
3034
|
+
};
|
|
3035
|
+
do await settle(); while (queuedSince());
|
|
3036
|
+
const before = answered(this.channels.get(channelKey), notYet(nextRegistration, "it isn't registered here now"));
|
|
3037
|
+
if (before !== null) return before;
|
|
3038
|
+
let failure: unknown;
|
|
3039
|
+
try {
|
|
3040
|
+
await this.applyDesiredChannelState(serverId, channelId);
|
|
3041
|
+
} catch (error) {
|
|
3042
|
+
failure = error;
|
|
3043
|
+
}
|
|
3044
|
+
// A registration that came back while the open was out queued its own
|
|
3045
|
+
// reconcile, which the answer waits for too. The answer says why the
|
|
3046
|
+
// channel isn't open now, so where the last work failed, its failure,
|
|
3047
|
+
// the last attempt's, is the reason: this open's may concern a
|
|
3048
|
+
// registration that work replaced.
|
|
3049
|
+
let later: unknown;
|
|
3050
|
+
do later = await settle(); while (queuedSince());
|
|
3051
|
+
failure = later ?? failure;
|
|
3052
|
+
const clause = failure !== undefined ? `: ${lifecycleClause(failure)}` : '';
|
|
3053
|
+
const after = this.channels.get(channelKey);
|
|
3054
|
+
const settled = answered(after, notYet(nextRegistration, `it isn't registered here any more${clause}`));
|
|
3055
|
+
if (settled !== null) return settled;
|
|
3056
|
+
return notYet(
|
|
3057
|
+
laterClause(after!), failure !== undefined ? `opening it now didn't complete${clause}` : "it isn't open now", true,
|
|
3058
|
+
);
|
|
3059
|
+
}
|
|
3060
|
+
|
|
2224
3061
|
/**
|
|
2225
3062
|
* End the active tune-out, returning the channel to `nextState`.
|
|
2226
3063
|
* The caller (tune-out coordinator) owns the dump/notice flow; this is
|
|
@@ -2313,16 +3150,52 @@ export class ChannelRegistry {
|
|
|
2313
3150
|
|
|
2314
3151
|
/**
|
|
2315
3152
|
* Publish into a channel on behalf of a named non-resident agent (the
|
|
2316
|
-
* subconscious's speak_in_channel). Same delivery path
|
|
2317
|
-
* channel_publish tool
|
|
3153
|
+
* subconscious's speak_in_channel). Same delivery path, and the same result
|
|
3154
|
+
* as the channel_publish tool apart from `reason` below. Its traces carry the agent's name and follow
|
|
3155
|
+
* the publish's outcome as deliverSpeech's do: speech-routed after a
|
|
3156
|
+
* confirmed delivery, and speech-route-failed otherwise, `unknown` when the
|
|
3157
|
+
* words may have been posted and `failed` when nothing was, a refusal before
|
|
3158
|
+
* the publish included. The routed trace carries no message id, as before:
|
|
3159
|
+
* nothing here reads one, and a consumer that edits the newest traced
|
|
3160
|
+
* message in a channel would have this post to edit when an interruption of
|
|
3161
|
+
* the resident matches none. A refusal of the destination before anything
|
|
3162
|
+
* went out carries its `reason` (PublishRefusalReason), so a caller can act
|
|
3163
|
+
* on why nothing was posted without reading the words.
|
|
2318
3164
|
*/
|
|
2319
3165
|
async publishForAgent(
|
|
2320
3166
|
channelId: string,
|
|
2321
3167
|
text: string,
|
|
2322
3168
|
agentName: string,
|
|
2323
|
-
): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> {
|
|
2324
|
-
|
|
2325
|
-
|
|
3169
|
+
): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean; reason?: PublishRefusalReason }> {
|
|
3170
|
+
// The server the publish goes to, as handleToolPublish finds it before
|
|
3171
|
+
// it awaits: the channel may be removed while the reply is pending.
|
|
3172
|
+
const serverId = this.findChannelEntry(channelId)?.serverId ?? '';
|
|
3173
|
+
const seen: { outcome?: PublishOutcome; refusal?: PublishRefusalReason } = {};
|
|
3174
|
+
const result = await this.handleToolPublish({ channelId, content: text }, undefined, seen, 'speak_in_channel');
|
|
3175
|
+
if (seen.outcome?.status === 'delivered') {
|
|
3176
|
+
this.emitTraceFn({
|
|
3177
|
+
type: 'mcpl:speech-routed',
|
|
3178
|
+
conversationId: agentName,
|
|
3179
|
+
serverId,
|
|
3180
|
+
channelId,
|
|
3181
|
+
delivered: true,
|
|
3182
|
+
textLen: text.length,
|
|
3183
|
+
text,
|
|
3184
|
+
});
|
|
3185
|
+
} else {
|
|
3186
|
+
this.emitTraceFn({
|
|
3187
|
+
type: 'mcpl:speech-route-failed',
|
|
3188
|
+
conversationId: agentName,
|
|
3189
|
+
channelId,
|
|
3190
|
+
// The publish's own reason and the connector's detail, as
|
|
3191
|
+
// deliverSpeech's trace gives them, or the refusal before any publish.
|
|
3192
|
+
reason: seen.outcome?.reason ?? result.error ?? 'the publish failed',
|
|
3193
|
+
textLen: text.length,
|
|
3194
|
+
outcome: seen.outcome?.status === 'unknown' ? 'unknown' : 'failed',
|
|
3195
|
+
...(seen.outcome?.detail !== undefined ? { detail: connectorText(seen.outcome.detail) } : {}),
|
|
3196
|
+
});
|
|
3197
|
+
}
|
|
3198
|
+
return seen.refusal !== undefined ? { ...result, reason: seen.refusal } : result;
|
|
2326
3199
|
}
|
|
2327
3200
|
|
|
2328
3201
|
/**
|
|
@@ -2423,6 +3296,7 @@ export class ChannelRegistry {
|
|
|
2423
3296
|
const removals = this.serverRemovals.get(serverId) ?? 0;
|
|
2424
3297
|
const previous = this.channelLifecycleTails.get(lifecycleKey) ?? Promise.resolve();
|
|
2425
3298
|
this.lifecycleOutstanding.set(lifecycleKey, (this.lifecycleOutstanding.get(lifecycleKey) ?? 0) + 1);
|
|
3299
|
+
this.lifecycleQueuedRemovals.set(lifecycleKey, removals);
|
|
2426
3300
|
const operation = previous.catch(() => {}).then(
|
|
2427
3301
|
() => this.runLifecycleOperation(serverId, channelId, removals, options),
|
|
2428
3302
|
);
|
|
@@ -2452,17 +3326,14 @@ export class ChannelRegistry {
|
|
|
2452
3326
|
let attempts = 0;
|
|
2453
3327
|
for (;;) {
|
|
2454
3328
|
if ((this.serverRemovals.get(serverId) ?? 0) !== removals) {
|
|
2455
|
-
throw new
|
|
2456
|
-
`
|
|
3329
|
+
throw new ChannelLifecycleError(
|
|
3330
|
+
`this host disconnected the "${serverId}" server while the change was being made`, 'cancelled',
|
|
2457
3331
|
);
|
|
2458
3332
|
}
|
|
2459
3333
|
const entry = this.channels.get(channelKey);
|
|
2460
|
-
if (!entry) throw new
|
|
3334
|
+
if (!entry) throw new ChannelLifecycleError(`the "${serverId}" server no longer lists the channel`, 'unlisted');
|
|
2461
3335
|
const server = this.serverRegistry.getServer(serverId);
|
|
2462
|
-
if (!server) throw
|
|
2463
|
-
if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
|
|
2464
|
-
throw new Error(`channels.lifecycle not in "${serverId}"'s effective grant (§14.1)`);
|
|
2465
|
-
}
|
|
3336
|
+
if (!server || !CapabilityGrant.of(server).has('channels.lifecycle')) throw this.lifecycleRefusal(serverId);
|
|
2466
3337
|
const descriptor = entry.descriptor;
|
|
2467
3338
|
const desired = this.getDesiredState(serverId, channelId);
|
|
2468
3339
|
const open = desired === 'open' || desired === 'tuned-out';
|
|
@@ -2471,11 +3342,14 @@ export class ChannelRegistry {
|
|
|
2471
3342
|
// release its queue slot and surface the failure to delivery joiners.
|
|
2472
3343
|
// Its open flag needs no change here: every retry follows a replaced
|
|
2473
3344
|
// registration, a retarget (already invalidated, whether the old
|
|
2474
|
-
// target's RPC was answered or failed),
|
|
2475
|
-
//
|
|
3345
|
+
// target's RPC was answered or failed), a receipt from before a
|
|
3346
|
+
// transport's close (invalidated by the close), or a receipt for this
|
|
3347
|
+
// very target.
|
|
2476
3348
|
if (attempts >= MAX_CHANNEL_LIFECYCLE_ATTEMPTS) {
|
|
2477
|
-
const error = new
|
|
2478
|
-
`
|
|
3349
|
+
const error = new ChannelLifecycleError(
|
|
3350
|
+
`the channel's address at the "${serverId}" server, or the decision about it, kept changing, and ${attempts} tries ` +
|
|
3351
|
+
"didn't settle it",
|
|
3352
|
+
'converge',
|
|
2479
3353
|
);
|
|
2480
3354
|
this.emitTraceFn({
|
|
2481
3355
|
type: 'mcpl:channel-reconcile-failed',
|
|
@@ -2484,7 +3358,14 @@ export class ChannelRegistry {
|
|
|
2484
3358
|
throw error;
|
|
2485
3359
|
}
|
|
2486
3360
|
attempts++;
|
|
3361
|
+
// A reconnect keeps the connection object, so the count of its
|
|
3362
|
+
// transport's closes (transportClosed) is what tells an answer from
|
|
3363
|
+
// the transport it was sent on apart from one on its successor: an
|
|
3364
|
+
// answer applied after a close it was sent before is from an older
|
|
3365
|
+
// transport, and confirms nothing.
|
|
3366
|
+
const closesAtSend = this.transportCloses.get(serverId) ?? 0;
|
|
2487
3367
|
const sameTarget = () => this.serverRegistry.getServer(serverId) === server &&
|
|
3368
|
+
(this.transportCloses.get(serverId) ?? 0) === closesAtSend &&
|
|
2488
3369
|
sameChannelTarget(entry.descriptor, descriptor);
|
|
2489
3370
|
let result: ChannelsOpenResult | undefined;
|
|
2490
3371
|
try {
|
|
@@ -2504,8 +3385,10 @@ export class ChannelRegistry {
|
|
|
2504
3385
|
// while not open). A failure from an older connection or target
|
|
2505
3386
|
// also withdraws the open flag.
|
|
2506
3387
|
if (this.channels.get(channelKey) === entry) {
|
|
2507
|
-
if (sameTarget())
|
|
2508
|
-
|
|
3388
|
+
if (sameTarget()) {
|
|
3389
|
+
this.unconfirmedChannelTargets.add(entry);
|
|
3390
|
+
if (!open) this.failedCloseTargets.add(entry);
|
|
3391
|
+
} else this.invalidateTransportConfirmation(entry);
|
|
2509
3392
|
}
|
|
2510
3393
|
// A retarget while this RPC was out queued nothing for the new
|
|
2511
3394
|
// target, since handleChanged leaves that to this operation. So a
|
|
@@ -2550,14 +3433,17 @@ export class ChannelRegistry {
|
|
|
2550
3433
|
// its sends either.
|
|
2551
3434
|
if (substitute !== undefined) {
|
|
2552
3435
|
this.invalidateTransportConfirmation(entry);
|
|
2553
|
-
throw new
|
|
2554
|
-
`
|
|
2555
|
-
(substitute.kind === 'other' ? 'a different channel' : 'a channel id
|
|
2556
|
-
|
|
3436
|
+
throw new ChannelLifecycleError(
|
|
3437
|
+
`the "${serverId}" server answered for ` +
|
|
3438
|
+
(substitute.kind === 'other' ? 'a different channel' : 'a channel whose id isn\'t a string') +
|
|
3439
|
+
', so nothing in its answer is used',
|
|
3440
|
+
'substituted',
|
|
2557
3441
|
);
|
|
2558
3442
|
}
|
|
3443
|
+
if (open) this.applyOpenDeclaration(entry, result);
|
|
2559
3444
|
entry.open = open;
|
|
2560
3445
|
this.unconfirmedChannelTargets.delete(entry);
|
|
3446
|
+
this.failedCloseTargets.delete(entry);
|
|
2561
3447
|
const now = this.getDesiredState(serverId, channelId);
|
|
2562
3448
|
if (open !== (now === 'open' || now === 'tuned-out')) continue;
|
|
2563
3449
|
return { open, result };
|
|
@@ -2567,10 +3453,36 @@ export class ChannelRegistry {
|
|
|
2567
3453
|
// on, nothing it does will open a new target.
|
|
2568
3454
|
const left = (this.lifecycleOutstanding.get(lifecycleKey) ?? 1) - 1;
|
|
2569
3455
|
if (left > 0) this.lifecycleOutstanding.set(lifecycleKey, left);
|
|
2570
|
-
else
|
|
3456
|
+
else {
|
|
3457
|
+
this.lifecycleOutstanding.delete(lifecycleKey);
|
|
3458
|
+
this.lifecycleQueuedRemovals.delete(lifecycleKey);
|
|
3459
|
+
}
|
|
2571
3460
|
}
|
|
2572
3461
|
}
|
|
2573
3462
|
|
|
3463
|
+
/** Whether lifecycle work already outstanding for the channel will carry out
|
|
3464
|
+
* its current decision: some is, and the newest was queued since its
|
|
3465
|
+
* server's registrations were last removed, so it won't cancel when it
|
|
3466
|
+
* would run. Each operation reads the decision when it runs. It answers for
|
|
3467
|
+
* now: a removal after it cancels that work too, and takes the channel's
|
|
3468
|
+
* entry with it, so the channel's next registration by channels/register,
|
|
3469
|
+
* or as added by channels/changed, carries the decision out, where this
|
|
3470
|
+
* host directs the lifecycle then. A push's registration
|
|
3471
|
+
* (ensureChannelRegistered) queues no lifecycle work. So where a push
|
|
3472
|
+
* re-makes an entry that a channels/changed removal took while an RPC for
|
|
3473
|
+
* the old entry was out, the operation running that RPC carries the
|
|
3474
|
+
* decision out for the push's entry only if the RPC is answered: a failed
|
|
3475
|
+
* one rethrows, since its retry covers only a retarget of its own entry.
|
|
3476
|
+
* This answers true meanwhile, so a channels/changed update that retargets
|
|
3477
|
+
* the push's entry is left to that operation, and stays undone after a
|
|
3478
|
+
* failed RPC until lifecycle work is queued for the channel again
|
|
3479
|
+
* (applyDesiredChannelState). No server we know of sends `updated` for a
|
|
3480
|
+
* channel it has removed. */
|
|
3481
|
+
private outstandingWorkCarriesOut(serverId: string, key: string): boolean {
|
|
3482
|
+
return this.lifecycleOutstanding.has(key) &&
|
|
3483
|
+
this.lifecycleQueuedRemovals.get(key) === (this.serverRemovals.get(serverId) ?? 0);
|
|
3484
|
+
}
|
|
3485
|
+
|
|
2574
3486
|
/**
|
|
2575
3487
|
* A server that answered an open with another channel really opened that
|
|
2576
3488
|
* one. When it's registered here and the host wants it closed, close it
|
|
@@ -2583,15 +3495,27 @@ export class ChannelRegistry {
|
|
|
2583
3495
|
private closeSubstitutedChannel(serverId: string, channelId: string): void {
|
|
2584
3496
|
if (!this.channels.has(`${serverId}:${channelId}`)) return;
|
|
2585
3497
|
if (this.getDesiredState(serverId, channelId) !== 'closed') return;
|
|
3498
|
+
this.applyThroughQueue(serverId, channelId);
|
|
3499
|
+
}
|
|
3500
|
+
|
|
3501
|
+
/**
|
|
3502
|
+
* Carry out a channel's decision through its lifecycle queue, for a
|
|
3503
|
+
* transport the server may have opened without the host's knowing: one it
|
|
3504
|
+
* opened for another channel's request (closeSubstitutedChannel), or one a
|
|
3505
|
+
* failed open's request may have reached (openChannelNow). The decision is
|
|
3506
|
+
* a closed one, except after a failed open whose put-back the server's
|
|
3507
|
+
* latest announcement or the policy lifted. A failure is reported as a
|
|
3508
|
+
* trace; the decision stands.
|
|
3509
|
+
*/
|
|
3510
|
+
private applyThroughQueue(serverId: string, channelId: string): void {
|
|
2586
3511
|
this.applyDesiredChannelState(serverId, channelId).catch((err) => {
|
|
2587
3512
|
// Removal while queued is cancellation, as in reconcileChannels, and
|
|
2588
3513
|
// exhausted convergence emitted its own diagnostic.
|
|
2589
3514
|
if (!this.channels.has(`${serverId}:${channelId}`) ||
|
|
2590
|
-
err instanceof
|
|
2591
|
-
err instanceof ChannelLifecycleConvergenceError) return;
|
|
3515
|
+
(err instanceof ChannelLifecycleError && (err.kind === 'cancelled' || err.kind === 'converge'))) return;
|
|
2592
3516
|
this.emitTraceFn({
|
|
2593
3517
|
type: 'mcpl:channel-reconcile-failed',
|
|
2594
|
-
serverId, channelId, desired:
|
|
3518
|
+
serverId, channelId, desired: this.getDesiredState(serverId, channelId), error: (err as Error).message,
|
|
2595
3519
|
});
|
|
2596
3520
|
});
|
|
2597
3521
|
}
|
|
@@ -2599,21 +3523,27 @@ export class ChannelRegistry {
|
|
|
2599
3523
|
/**
|
|
2600
3524
|
* Join outstanding lifecycle work without recording new intent. Retargeting
|
|
2601
3525
|
* may have invalidated open confirmation while the decision remains pending.
|
|
2602
|
-
* A close that settles during the wait remains authoritative
|
|
2603
|
-
*
|
|
3526
|
+
* A close that settles during the wait remains authoritative where the host
|
|
3527
|
+
* directs the server's lifecycle (closeStands); waiting for it is not an
|
|
3528
|
+
* instruction to open the channel again.
|
|
2604
3529
|
*/
|
|
2605
3530
|
private async waitForOpenChannel(entry: ChannelEntry): Promise<ChannelEntry> {
|
|
2606
3531
|
const lifecycleKey = this.lifecycleKey(entry.serverId, entry.descriptor.id);
|
|
3532
|
+
// The failure of the last work waited on, if it failed, which says why a
|
|
3533
|
+
// channel it left unopened isn't open.
|
|
3534
|
+
let failed: unknown;
|
|
2607
3535
|
for (;;) {
|
|
2608
3536
|
const pending = this.channelLifecycleTails.get(lifecycleKey);
|
|
2609
3537
|
if (!pending) break;
|
|
2610
3538
|
try {
|
|
2611
3539
|
await pending;
|
|
3540
|
+
failed = undefined;
|
|
2612
3541
|
} catch (error) {
|
|
2613
3542
|
// A redundant backscroll failure can leave a known-open channel
|
|
2614
3543
|
// usable. Exhausted convergence is different: it never established
|
|
2615
3544
|
// the current target, so joined delivery must fail visibly.
|
|
2616
|
-
if (error instanceof
|
|
3545
|
+
if (error instanceof ChannelLifecycleError && error.kind === 'converge') throw error;
|
|
3546
|
+
failed = error;
|
|
2617
3547
|
}
|
|
2618
3548
|
// A settled tail releases its own key. One still current after it
|
|
2619
3549
|
// settled would be awaited again without ever yielding to the event
|
|
@@ -2621,9 +3551,18 @@ export class ChannelRegistry {
|
|
|
2621
3551
|
if (this.channelLifecycleTails.get(lifecycleKey) === pending) break;
|
|
2622
3552
|
}
|
|
2623
3553
|
const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
|
|
2624
|
-
if (!current) throw new
|
|
2625
|
-
|
|
2626
|
-
|
|
3554
|
+
if (!current) throw new ChannelLifecycleError(CHANNEL_REMOVED_CLAUSE, 'removed');
|
|
3555
|
+
// A close that stands is what the words met, whatever became of an open,
|
|
3556
|
+
// and whether or not the close itself took: one that failed may have
|
|
3557
|
+
// left the transport open.
|
|
3558
|
+
if (this.closeStands(current)) {
|
|
3559
|
+
throw new ChannelLifecycleError('a decision to close the channel came before these words went out', 'not-open');
|
|
3560
|
+
}
|
|
3561
|
+
if (!current.open) {
|
|
3562
|
+
throw new ChannelLifecycleError(
|
|
3563
|
+
"the channel isn't confirmed open" + (failed !== undefined ? `: ${lifecycleClause(failed)}${refusalRemedy(failed)}` : ''),
|
|
3564
|
+
'not-open',
|
|
3565
|
+
);
|
|
2627
3566
|
}
|
|
2628
3567
|
return current;
|
|
2629
3568
|
}
|
|
@@ -2688,9 +3627,8 @@ export class ChannelRegistry {
|
|
|
2688
3627
|
// Exhausted convergence and a substituted answer already emitted
|
|
2689
3628
|
// their own diagnostics.
|
|
2690
3629
|
if (!this.channels.has(`${serverId}:${channel.id}`) ||
|
|
2691
|
-
err instanceof
|
|
2692
|
-
|
|
2693
|
-
err instanceof ChannelOpenSubstitutionError) return;
|
|
3630
|
+
(err instanceof ChannelLifecycleError &&
|
|
3631
|
+
(err.kind === 'cancelled' || err.kind === 'converge' || err.kind === 'substituted'))) return;
|
|
2694
3632
|
this.emitTraceFn({
|
|
2695
3633
|
type: 'mcpl:channel-reconcile-failed',
|
|
2696
3634
|
serverId,
|
|
@@ -2740,6 +3678,17 @@ export class ChannelRegistry {
|
|
|
2740
3678
|
return this.findChannelEntry(channelId)?.serverId ?? null;
|
|
2741
3679
|
}
|
|
2742
3680
|
|
|
3681
|
+
/**
|
|
3682
|
+
* The server that declared this channel inbound-only (isInboundOnly), or
|
|
3683
|
+
* undefined when it isn't one. The notices name that server. A caller that
|
|
3684
|
+
* knows the channel's server passes it; otherwise the channel is found by
|
|
3685
|
+
* its id alone (findChannelEntry).
|
|
3686
|
+
*/
|
|
3687
|
+
inboundOnlyServer(channelId: string, serverId?: string): string | undefined {
|
|
3688
|
+
const entry = serverId ? this.channels.get(`${serverId}:${channelId}`) : this.findChannelEntry(channelId);
|
|
3689
|
+
return entry && isInboundOnly(entry) ? entry.serverId : undefined;
|
|
3690
|
+
}
|
|
3691
|
+
|
|
2743
3692
|
/**
|
|
2744
3693
|
* Find a channel entry by channelId (searches across all servers).
|
|
2745
3694
|
* Returns the first match.
|
|
@@ -2757,19 +3706,28 @@ export class ChannelRegistry {
|
|
|
2757
3706
|
channelId: string,
|
|
2758
3707
|
serverId?: string,
|
|
2759
3708
|
): { entry?: ChannelEntry; error?: string } {
|
|
2760
|
-
const
|
|
2761
|
-
|
|
2762
|
-
);
|
|
3709
|
+
const withId = [...this.channels.values()].filter((entry) => entry.descriptor.id === channelId);
|
|
3710
|
+
const matches = withId.filter((entry) => !serverId || entry.serverId === serverId);
|
|
2763
3711
|
if (matches.length === 0) {
|
|
3712
|
+
if (serverId && withId.length > 0) {
|
|
3713
|
+
// Leaving serverId out works only when one server has the id.
|
|
3714
|
+
const servers = withId.map((entry) => `"${entry.serverId}"`);
|
|
3715
|
+
return {
|
|
3716
|
+
error: `No channel "${channelId}" is on server "${serverId}", so nothing ran. ` + (servers.length === 1
|
|
3717
|
+
? `It's on server ${servers[0]}: call again with serverId ${servers[0]}, or leave serverId out.`
|
|
3718
|
+
: `It's on servers ${servers.join(', ')}: call again with serverId set to one of them.`),
|
|
3719
|
+
};
|
|
3720
|
+
}
|
|
2764
3721
|
return {
|
|
2765
3722
|
error: serverId
|
|
2766
|
-
? `
|
|
2767
|
-
: `
|
|
3723
|
+
? `No channel "${channelId}" is on server "${serverId}", so nothing ran. channel_list shows the channels and their servers.`
|
|
3724
|
+
: `No channel has the id "${channelId}", so nothing ran. channel_list shows the channel ids.`,
|
|
2768
3725
|
};
|
|
2769
3726
|
}
|
|
2770
3727
|
if (matches.length > 1) {
|
|
2771
3728
|
return {
|
|
2772
|
-
error: `Channel
|
|
3729
|
+
error: `Channel "${channelId}" is on more than one server (${matches.map((entry) => `"${entry.serverId}"`).join(', ')}), ` +
|
|
3730
|
+
'so nothing ran. Call again with serverId set to one of them.',
|
|
2773
3731
|
};
|
|
2774
3732
|
}
|
|
2775
3733
|
return { entry: matches[0] };
|
|
@@ -2823,15 +3781,31 @@ export class ChannelRegistry {
|
|
|
2823
3781
|
/**
|
|
2824
3782
|
* Record durable desired-open intent, then queue the channel's lifecycle
|
|
2825
3783
|
* work (applyDesiredChannelState), which tells the server to subscribe and
|
|
2826
|
-
* marks the live entry open on a receipt for its current target
|
|
2827
|
-
*
|
|
3784
|
+
* marks the live entry open on a receipt for its current target, applying
|
|
3785
|
+
* an inbound-only declaration the answer carries (applyOpenDeclaration).
|
|
3786
|
+
* Every open the agent or its speech asks for — the channel_open tool, delivery
|
|
2828
3787
|
* into a closed locus, an explicit send into a closed channel — comes
|
|
2829
3788
|
* here. Reconciliation queues that work directly, as the closes do
|
|
2830
3789
|
* (channel_close, a decline, and the close of a substituted channel).
|
|
2831
|
-
* Desired state is recorded BEFORE the server round-trip
|
|
2832
|
-
*
|
|
2833
|
-
*
|
|
2834
|
-
*
|
|
3790
|
+
* Desired state is recorded BEFORE the server round-trip. The agent's own
|
|
3791
|
+
* open is a decision about the channel: it sticks even if the subscribe
|
|
3792
|
+
* fails, and reconciliation retries later. An open by speech or a reply is
|
|
3793
|
+
* incidental to its words: when its round-trip fails, the decision it
|
|
3794
|
+
* wrote goes back to the one it replaced, so the agent, told the channel
|
|
3795
|
+
* is closed and the open failed, isn't later put into it by that decision.
|
|
3796
|
+
* That holds only while the record is still the one it wrote: a decision
|
|
3797
|
+
* made meanwhile stands, and the failure then comes as `overtaken`
|
|
3798
|
+
* (ChannelLifecycleError). Where the decision put back is a pure default,
|
|
3799
|
+
* nobody had decided, so the channel then follows its server's latest
|
|
3800
|
+
* announcement and the policy: a re-announcement during the round-trip
|
|
3801
|
+
* found the open's own record and lifted nothing. If they select it for
|
|
3802
|
+
* opening, the failure says so (`selected-again`). And since
|
|
3803
|
+
* a failed request may have reached the server, the decision that stands
|
|
3804
|
+
* is carried out on the wire too. An open by speech or a reply that can't
|
|
3805
|
+
* send its request, on a server without channels.lifecycle, writes
|
|
3806
|
+
* nothing. Automatic delivery needs open transport, not a change of
|
|
3807
|
+
* attention: preserve tune-out epochs both before and during the
|
|
3808
|
+
* round-trip.
|
|
2835
3809
|
*
|
|
2836
3810
|
* `opened` says whether this call turned a closed channel open, which is
|
|
2837
3811
|
* what the delivery and reply notices tell the resident. Reconfirming the
|
|
@@ -2843,17 +3817,140 @@ export class ChannelRegistry {
|
|
|
2843
3817
|
history?: ChannelHistoryRequest,
|
|
2844
3818
|
): Promise<{ result: ChannelsOpenResult; opened: boolean }> {
|
|
2845
3819
|
const transportOnly = source !== 'agent-tool';
|
|
3820
|
+
const key = this.lifecycleKey(entry.serverId, entry.descriptor.id);
|
|
3821
|
+
if (transportOnly) {
|
|
3822
|
+
// Without channels.lifecycle the open fails before any request leaves
|
|
3823
|
+
// (§14.1), as it does without the server, so an incidental open would
|
|
3824
|
+
// only write a decision to put back. It writes none.
|
|
3825
|
+
const server = this.serverRegistry.getServer(entry.serverId);
|
|
3826
|
+
if (!server || !CapabilityGrant.of(server).has('channels.lifecycle')) throw this.lifecycleRefusal(entry.serverId);
|
|
3827
|
+
}
|
|
3828
|
+
// The decision this open replaces. A channel with none on record, as one
|
|
3829
|
+
// registered from a push, was closed by default: nobody had decided.
|
|
3830
|
+
const replaced = this.desiredStates.get(key) ?? { state: 'closed' as const, source: 'default-closed' };
|
|
2846
3831
|
const before = this.getDesiredState(entry.serverId, entry.descriptor.id);
|
|
2847
3832
|
const opened = before !== 'open' && before !== 'tuned-out';
|
|
2848
3833
|
if (!transportOnly || before !== 'tuned-out') {
|
|
2849
|
-
|
|
3834
|
+
// A decision the log refused changed nothing, and its words say so
|
|
3835
|
+
// rather than read as one a later decision overtook.
|
|
3836
|
+
try {
|
|
3837
|
+
this.setDesiredState(entry.serverId, entry.descriptor.id, 'open', source);
|
|
3838
|
+
} catch (err) {
|
|
3839
|
+
throw unrecordedDecision(err);
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
3842
|
+
// The record an incidental open wrote, to put back if its round-trip fails.
|
|
3843
|
+
const wrote = transportOnly && opened ? this.desiredStates.get(key) : undefined;
|
|
3844
|
+
try {
|
|
3845
|
+
const applied = await this.applyDesiredChannelState(entry.serverId, entry.descriptor.id, { history });
|
|
3846
|
+
const desired = this.getDesiredState(entry.serverId, entry.descriptor.id);
|
|
3847
|
+
if (!applied.open || (desired !== 'open' && !(transportOnly && desired === 'tuned-out'))) {
|
|
3848
|
+
throw new ChannelLifecycleError(supersededClause('open', desired), 'superseded');
|
|
3849
|
+
}
|
|
3850
|
+
return { result: applied.result!, opened };
|
|
3851
|
+
} catch (err) {
|
|
3852
|
+
if (wrote && this.desiredStates.get(key) !== wrote) {
|
|
3853
|
+
// That decision stands, and the failure says so where its own clause
|
|
3854
|
+
// doesn't, as a superseded open's does. What helps turns on that
|
|
3855
|
+
// decision, so no step is offered.
|
|
3856
|
+
const own = lifecycleClause(err);
|
|
3857
|
+
throw new ChannelLifecycleError(
|
|
3858
|
+
err instanceof ChannelLifecycleError && err.kind === 'superseded'
|
|
3859
|
+
? own : afterClause(own, 'a decision about the channel made while the open was out stands'),
|
|
3860
|
+
'overtaken',
|
|
3861
|
+
undefined,
|
|
3862
|
+
{ cause: err },
|
|
3863
|
+
);
|
|
3864
|
+
}
|
|
3865
|
+
if (wrote) {
|
|
3866
|
+
try {
|
|
3867
|
+
this.setDesiredState(entry.serverId, entry.descriptor.id, replaced.state, replaced.source);
|
|
3868
|
+
} catch (putBackErr) {
|
|
3869
|
+
// The caller reports both: the channel isn't open, but it stays
|
|
3870
|
+
// selected for opening, so reconciliation may open it later.
|
|
3871
|
+
throw new ChannelLifecycleError(
|
|
3872
|
+
afterClause(lifecycleClause(err), 'the channel stays selected for opening, because this host couldn\'t record ' +
|
|
3873
|
+
`undoing that: ${requestErrorText(putBackErr)}`),
|
|
3874
|
+
'put-back-unrecorded',
|
|
3875
|
+
undefined,
|
|
3876
|
+
{ cause: err },
|
|
3877
|
+
);
|
|
3878
|
+
}
|
|
3879
|
+
// The decision put back is a closed one: an open that turned the
|
|
3880
|
+
// channel open replaced no open decision. Where it's a pure default,
|
|
3881
|
+
// nobody had decided, so the channel follows its server's latest
|
|
3882
|
+
// announcement and the policy, not the ones from before the open: a
|
|
3883
|
+
// re-announcement during the round-trip found the open's own record
|
|
3884
|
+
// and lifted nothing. The lift is ensureInitialDesiredState's own,
|
|
3885
|
+
// which leaves every real decision alone. One that can't be recorded
|
|
3886
|
+
// leaves the default, closed.
|
|
3887
|
+
const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
|
|
3888
|
+
let selectedAgain: Exclude<SelectedForOpening, 'unrecorded'> | undefined;
|
|
3889
|
+
if (current) {
|
|
3890
|
+
try {
|
|
3891
|
+
this.ensureInitialDesiredState(entry.serverId, current.descriptor);
|
|
3892
|
+
} catch (liftErr) {
|
|
3893
|
+
console.error(
|
|
3894
|
+
`[channel] ${entry.serverId}:${logValue(entry.descriptor.id)}: the lift after a failed open couldn't be recorded:`,
|
|
3895
|
+
liftErr,
|
|
3896
|
+
);
|
|
3897
|
+
}
|
|
3898
|
+
const now = this.desiredStates.get(key);
|
|
3899
|
+
if (now?.state === 'open' && (now.source === 'server-bootstrap' || now.source === 'subscription-policy')) {
|
|
3900
|
+
selectedAgain = now.source;
|
|
3901
|
+
}
|
|
3902
|
+
}
|
|
3903
|
+
// The failed request may have reached the server, so the decision
|
|
3904
|
+
// that stands is carried out through the queue, as a decline's close
|
|
3905
|
+
// is, unless other lifecycle work for the channel is queued that will
|
|
3906
|
+
// carry it out anyway: work queued before its server's registrations
|
|
3907
|
+
// were removed cancels instead. (An incidental open gets here only
|
|
3908
|
+
// where the host directed the lifecycle when it opened: see the grant
|
|
3909
|
+
// check above. Where the grant has lost channels.lifecycle since, as a
|
|
3910
|
+
// transport drop clears it, the queued work fails for it, and the
|
|
3911
|
+
// channel's next registration by channels/register, or as added by
|
|
3912
|
+
// channels/changed, carries the decision out, where this host directs
|
|
3913
|
+
// the lifecycle then.)
|
|
3914
|
+
if (current && !this.outstandingWorkCarriesOut(entry.serverId, key)) {
|
|
3915
|
+
this.applyThroughQueue(entry.serverId, entry.descriptor.id);
|
|
3916
|
+
}
|
|
3917
|
+
if (selectedAgain) {
|
|
3918
|
+
throw new ChannelLifecycleError(
|
|
3919
|
+
afterClause(lifecycleClause(err), 'the channel is selected for opening again, because ' +
|
|
3920
|
+
(selectedAgain === 'server-bootstrap' ? 'its server lists it as open by default' : 'the subscription policy admits it')),
|
|
3921
|
+
'selected-again',
|
|
3922
|
+
undefined,
|
|
3923
|
+
{ by: selectedAgain, cause: err },
|
|
3924
|
+
);
|
|
3925
|
+
}
|
|
3926
|
+
}
|
|
3927
|
+
throw err;
|
|
2850
3928
|
}
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
3929
|
+
}
|
|
3930
|
+
|
|
3931
|
+
/**
|
|
3932
|
+
* An open's answer carries the server's descriptor for the channel
|
|
3933
|
+
* (§14.3), the first place a lazily registered channel's own declaration
|
|
3934
|
+
* can arrive. runLifecycleOperation hands it here, wherever the host opens
|
|
3935
|
+
* a channel, once the answer is known to be for the channel's current
|
|
3936
|
+
* entry and target. An answer that names another channel never arrives
|
|
3937
|
+
* here: it's a substitution, which fails the open. One whose channel names
|
|
3938
|
+
* no id is this channel's, as runLifecycleOperation takes it, so its
|
|
3939
|
+
* declaration applies too.
|
|
3940
|
+
*
|
|
3941
|
+
* Only an inbound-only declaration is applied. The answer isn't always the
|
|
3942
|
+
* server's latest word: a `channels/changed` that arrives in the same read
|
|
3943
|
+
* as the answer is handled before the open's operation resumes, so the
|
|
3944
|
+
* answer is applied over it. Applying only a refusal keeps that race
|
|
3945
|
+
* fail-closed: a stale answer can keep a channel refused until the
|
|
3946
|
+
* server's next update, but never lift a newer refusal. A server lifts its
|
|
3947
|
+
* declaration by changing or registering the channel again.
|
|
3948
|
+
*/
|
|
3949
|
+
private applyOpenDeclaration(entry: ChannelEntry, answer: unknown): void {
|
|
3950
|
+
const declared = (answer as { channel?: { direction?: unknown } } | undefined)?.channel;
|
|
3951
|
+
if (declared?.direction === 'inbound') {
|
|
3952
|
+
entry.descriptor = { ...entry.descriptor, direction: 'inbound' };
|
|
2855
3953
|
}
|
|
2856
|
-
return { result: applied.result!, opened };
|
|
2857
3954
|
}
|
|
2858
3955
|
|
|
2859
3956
|
/**
|
|
@@ -2869,9 +3966,28 @@ export class ChannelRegistry {
|
|
|
2869
3966
|
*
|
|
2870
3967
|
* Ambiguity is an error carrying candidates — never a guess: this is the
|
|
2871
3968
|
* mechanism that makes prose misdelivery structurally impossible.
|
|
3969
|
+
*
|
|
3970
|
+
* `forSpeech` is for a target the agent's speech will be posted to: a
|
|
3971
|
+
* channel its server declared inbound-only (isInboundOnly) then resolves
|
|
3972
|
+
* to an error that says so, since nothing is published there, and that
|
|
3973
|
+
* names the channel as `refusedChannelId`, so a caller can tell a refused
|
|
3974
|
+
* channel from a name that resolved to nothing. Without it, as for reading
|
|
3975
|
+
* a channel's history, any registered channel resolves.
|
|
2872
3976
|
*/
|
|
2873
3977
|
resolveProseTarget(
|
|
2874
3978
|
spec: string,
|
|
3979
|
+
opts?: { forSpeech?: boolean },
|
|
3980
|
+
): { channelId: string; label?: string } | { error: string; candidates?: string[]; refusedChannelId?: string } {
|
|
3981
|
+
const resolved = this.resolveTargetByName(spec);
|
|
3982
|
+
if (!opts?.forSpeech || 'error' in resolved) return resolved;
|
|
3983
|
+
const entry = this.findChannelEntry(resolved.channelId);
|
|
3984
|
+
return entry && isInboundOnly(entry)
|
|
3985
|
+
? { error: `${resolved.channelId} is ${inboundOnlyReason(entry)}`, refusedChannelId: resolved.channelId }
|
|
3986
|
+
: resolved;
|
|
3987
|
+
}
|
|
3988
|
+
|
|
3989
|
+
private resolveTargetByName(
|
|
3990
|
+
spec: string,
|
|
2875
3991
|
): { channelId: string; label?: string } | { error: string; candidates?: string[] } {
|
|
2876
3992
|
const entries = [...this.channels.values()];
|
|
2877
3993
|
const trimmed = spec.trim();
|
|
@@ -3030,7 +4146,7 @@ export class ChannelRegistry {
|
|
|
3030
4146
|
];
|
|
3031
4147
|
for (const c of candidates) {
|
|
3032
4148
|
if (/\s/.test(c) || c === '#') continue;
|
|
3033
|
-
const r = this.resolveProseTarget(c);
|
|
4149
|
+
const r = this.resolveProseTarget(c, { forSpeech: true });
|
|
3034
4150
|
if ('channelId' in r && r.channelId === d.id) return c;
|
|
3035
4151
|
}
|
|
3036
4152
|
return undefined;
|
|
@@ -3042,7 +4158,17 @@ export class ChannelRegistry {
|
|
|
3042
4158
|
* engaging a channel opens it, so typing indicators, reaction machinery,
|
|
3043
4159
|
* and inbound forwarding all come alive with the first outbound message.
|
|
3044
4160
|
* Accepts composite MCPL ids or raw server-internal ids (explicit send
|
|
3045
|
-
* tools receive whatever the agent typed).
|
|
4161
|
+
* tools receive whatever the agent typed). An `open-failed` answer says
|
|
4162
|
+
* whether the open that failed was one that would have opened a channel
|
|
4163
|
+
* not selected open (`opening`), as opposed to one reconfirming the
|
|
4164
|
+
* transport of a channel already selected open or tuned out. It isn't one
|
|
4165
|
+
* either where a decision made during its round-trip replaced it
|
|
4166
|
+
* (`overtaken`), or where queued lifecycle work left the
|
|
4167
|
+
* channel closed before any open was sent. `selectedForOpening` says the
|
|
4168
|
+
* channel is selected for opening after the failure, and why: the put-back
|
|
4169
|
+
* couldn't be recorded (`put-back-unrecorded`), or the server's latest
|
|
4170
|
+
* announcement or the policy lifted the default it restored
|
|
4171
|
+
* (`selected-again`).
|
|
3046
4172
|
*/
|
|
3047
4173
|
async openIfClosedForSend(
|
|
3048
4174
|
rawChannelId: string,
|
|
@@ -3051,6 +4177,8 @@ export class ChannelRegistry {
|
|
|
3051
4177
|
status: 'opened' | 'already-open' | 'unknown-channel' | 'ambiguous' | 'open-failed';
|
|
3052
4178
|
channelId?: string;
|
|
3053
4179
|
label?: string;
|
|
4180
|
+
opening?: boolean;
|
|
4181
|
+
selectedForOpening?: SelectedForOpening;
|
|
3054
4182
|
}> {
|
|
3055
4183
|
let matches = [...this.channels.values()].filter(
|
|
3056
4184
|
(e) => e.descriptor.id === rawChannelId && (!serverId || e.serverId === serverId),
|
|
@@ -3065,6 +4193,7 @@ export class ChannelRegistry {
|
|
|
3065
4193
|
if (matches.length === 0) return { status: 'unknown-channel' };
|
|
3066
4194
|
if (matches.length > 1) return { status: 'ambiguous' };
|
|
3067
4195
|
let entry = matches[0]!;
|
|
4196
|
+
let opening = false;
|
|
3068
4197
|
try {
|
|
3069
4198
|
if (this.hasPendingLifecycle(entry)) {
|
|
3070
4199
|
entry = await this.waitForOpenChannel(entry);
|
|
@@ -3073,6 +4202,7 @@ export class ChannelRegistry {
|
|
|
3073
4202
|
if (entry.open && (desired === 'open' || desired === 'tuned-out')) {
|
|
3074
4203
|
return { status: 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
|
|
3075
4204
|
}
|
|
4205
|
+
opening = desired !== 'open' && desired !== 'tuned-out';
|
|
3076
4206
|
const { opened } = await this.openChannelNow(entry, 'opened-by-reply');
|
|
3077
4207
|
entry = await this.waitForOpenChannel(entry);
|
|
3078
4208
|
if (opened) {
|
|
@@ -3084,6 +4214,10 @@ export class ChannelRegistry {
|
|
|
3084
4214
|
}
|
|
3085
4215
|
return { status: opened ? 'opened' : 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
|
|
3086
4216
|
} catch (err) {
|
|
4217
|
+
// Where a decision made during the open's round-trip replaced it, such
|
|
4218
|
+
// as the agent's own close or channel_open, the channel is as that
|
|
4219
|
+
// decision left it, and the agent isn't told the open failed.
|
|
4220
|
+
if (err instanceof ChannelLifecycleError && err.kind === 'overtaken') opening = false;
|
|
3087
4221
|
this.emitTraceFn({
|
|
3088
4222
|
type: 'mcpl:channel-open-failed',
|
|
3089
4223
|
serverId: entry.serverId,
|
|
@@ -3091,7 +4225,11 @@ export class ChannelRegistry {
|
|
|
3091
4225
|
error: (err as Error).message,
|
|
3092
4226
|
});
|
|
3093
4227
|
const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
|
|
3094
|
-
return { status: 'open-failed', channelId: entry.descriptor.id,
|
|
4228
|
+
return { status: 'open-failed', channelId: entry.descriptor.id, opening,
|
|
4229
|
+
// Where the channel is selected for opening after the failure, the
|
|
4230
|
+
// agent is told so and why, as speech's failure says.
|
|
4231
|
+
...(err instanceof ChannelLifecycleError && err.kind === 'put-back-unrecorded' ? { selectedForOpening: 'unrecorded' as const } : {}),
|
|
4232
|
+
...(err instanceof ChannelLifecycleError && err.kind === 'selected-again' ? { selectedForOpening: err.by } : {}),
|
|
3095
4233
|
...(current ? { label: current.descriptor.label } : {}) };
|
|
3096
4234
|
}
|
|
3097
4235
|
}
|
|
@@ -3115,23 +4253,26 @@ export class ChannelRegistry {
|
|
|
3115
4253
|
// A tuned-out channel is transport-open but attention-diverted; a plain
|
|
3116
4254
|
// open would silently discard the epoch (and its pending backlog dump).
|
|
3117
4255
|
// Cancelling is the tune-out coordinator's flow, reached via its own
|
|
3118
|
-
// tool — refuse with the pointer rather than eat the state.
|
|
3119
|
-
// result, the agent reads the pointer itself; `data` names the refusal
|
|
3120
|
-
// for an in-process caller.
|
|
4256
|
+
// tool — refuse with the pointer rather than eat the state.
|
|
3121
4257
|
if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
|
|
3122
|
-
return
|
|
3123
|
-
success: false,
|
|
3124
|
-
isError: true,
|
|
3125
|
-
error:
|
|
3126
|
-
`Channel ${input.channelId} is tuned out. Cancel the tune-out ` +
|
|
3127
|
-
`(tune_out with mode: 'cancel') to resume normal attention — ` +
|
|
3128
|
-
`cancelling delivers the diverted backlog.`,
|
|
3129
|
-
data: { refusal: 'tuned-out', channelId: input.channelId },
|
|
3130
|
-
};
|
|
4258
|
+
return tunedOutRefusal('channel_open', input.channelId);
|
|
3131
4259
|
}
|
|
3132
4260
|
|
|
3133
4261
|
const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open';
|
|
3134
4262
|
if (entry.open && alreadyDesiredOpen && !input.backscroll && !this.hasPendingLifecycle(entry)) {
|
|
4263
|
+
// Open already, perhaps because speech, a reply, the server or a
|
|
4264
|
+
// policy opened it: the agent's open is still its decision, and is
|
|
4265
|
+
// recorded as one (setDesiredState), so the explicit-open guard of
|
|
4266
|
+
// handleToolClose reads it.
|
|
4267
|
+
try {
|
|
4268
|
+
this.setDesiredState(entry.serverId, input.channelId, 'open', 'agent-tool');
|
|
4269
|
+
} catch (err) {
|
|
4270
|
+
return {
|
|
4271
|
+
success: false,
|
|
4272
|
+
error: `Channel ${input.channelId} is already open, but recording your decision to open it failed: ${(err as Error).message}`,
|
|
4273
|
+
isError: true,
|
|
4274
|
+
};
|
|
4275
|
+
}
|
|
3135
4276
|
return {
|
|
3136
4277
|
success: true,
|
|
3137
4278
|
data: { channelId: input.channelId, status: 'already open' },
|
|
@@ -3157,17 +4298,12 @@ export class ChannelRegistry {
|
|
|
3157
4298
|
};
|
|
3158
4299
|
} catch (err) {
|
|
3159
4300
|
// The decision was recorded before the round-trip and outlives its
|
|
3160
|
-
// failure.
|
|
3161
|
-
// would be the resident's first sign
|
|
3162
|
-
|
|
3163
|
-
const stillSelected = current !== undefined && !current.open &&
|
|
3164
|
-
this.getDesiredState(entry.serverId, input.channelId) === 'open';
|
|
4301
|
+
// failure. The text says so where it stands: otherwise a later
|
|
4302
|
+
// delivery that opens the channel would be the resident's first sign
|
|
4303
|
+
// that it was still wanted open.
|
|
3165
4304
|
return {
|
|
3166
4305
|
success: false,
|
|
3167
|
-
error:
|
|
3168
|
-
(stillSelected
|
|
3169
|
-
? ' (it stays selected for opening: a later delivery or reconnect tries again, and channel_close withdraws that)'
|
|
3170
|
-
: ''),
|
|
4306
|
+
error: this.lifecycleFailureText('open', entry, err, { history: !!input.backscroll }),
|
|
3171
4307
|
isError: true,
|
|
3172
4308
|
};
|
|
3173
4309
|
}
|
|
@@ -3202,15 +4338,6 @@ export class ChannelRegistry {
|
|
|
3202
4338
|
};
|
|
3203
4339
|
}
|
|
3204
4340
|
|
|
3205
|
-
const alreadyDesiredClosed = this.getDesiredState(entry.serverId, input.channelId) === 'closed';
|
|
3206
|
-
if (!entry.open && alreadyDesiredClosed && !this.hasPendingLifecycle(entry) &&
|
|
3207
|
-
!this.unconfirmedChannelTargets.has(entry)) {
|
|
3208
|
-
return {
|
|
3209
|
-
success: true,
|
|
3210
|
-
data: { channelId: input.channelId, status: 'already closed' },
|
|
3211
|
-
};
|
|
3212
|
-
}
|
|
3213
|
-
|
|
3214
4341
|
// Provenance comes from trusted dispatch, not from self-description:
|
|
3215
4342
|
// only module-origin calls may record a machine source, and only from
|
|
3216
4343
|
// the closed vocabulary. Agent-origin (or origin-less — the safe
|
|
@@ -3231,6 +4358,27 @@ export class ChannelRegistry {
|
|
|
3231
4358
|
closeSource = input.source;
|
|
3232
4359
|
}
|
|
3233
4360
|
|
|
4361
|
+
const alreadyDesiredClosed = this.getDesiredState(entry.serverId, input.channelId) === 'closed';
|
|
4362
|
+
if (!entry.open && alreadyDesiredClosed && !this.hasPendingLifecycle(entry) &&
|
|
4363
|
+
!this.unconfirmedChannelTargets.has(entry)) {
|
|
4364
|
+
// Closed already, perhaps only by default: the agent's close is still
|
|
4365
|
+
// its decision, and is recorded as one (setDesiredState), so a later
|
|
4366
|
+
// registration's lift of a default leaves the channel closed.
|
|
4367
|
+
try {
|
|
4368
|
+
this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
|
|
4369
|
+
} catch (err) {
|
|
4370
|
+
return {
|
|
4371
|
+
success: false,
|
|
4372
|
+
error: `Channel ${input.channelId} is already closed, but recording the decision to close it failed: ${(err as Error).message}`,
|
|
4373
|
+
isError: true,
|
|
4374
|
+
};
|
|
4375
|
+
}
|
|
4376
|
+
return {
|
|
4377
|
+
success: true,
|
|
4378
|
+
data: { channelId: input.channelId, status: 'already closed' },
|
|
4379
|
+
};
|
|
4380
|
+
}
|
|
4381
|
+
|
|
3234
4382
|
// Housekeeping must not override stated intent (issue #5: GC closes wore
|
|
3235
4383
|
// the agent's badge and reset explicitly-opened doors). A machine-sourced
|
|
3236
4384
|
// close of a channel whose current desired state is an agent/operator
|
|
@@ -3253,40 +4401,30 @@ export class ChannelRegistry {
|
|
|
3253
4401
|
}
|
|
3254
4402
|
}
|
|
3255
4403
|
|
|
3256
|
-
|
|
4404
|
+
// A decision the log refused changed nothing, and the answer says so.
|
|
4405
|
+
try {
|
|
4406
|
+
this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource);
|
|
4407
|
+
} catch (err) {
|
|
4408
|
+
return { success: false, error: this.lifecycleFailureText('close', entry, unrecordedDecision(err)), isError: true };
|
|
4409
|
+
}
|
|
3257
4410
|
this.stopTyping(input.channelId);
|
|
3258
4411
|
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
error: `Server not found: ${entry.serverId}`,
|
|
3264
|
-
isError: true,
|
|
3265
|
-
};
|
|
4412
|
+
// Refused now, the close stays recorded for the server's next
|
|
4413
|
+
// registration, and the text says so.
|
|
4414
|
+
if (!this.directsLifecycle(entry.serverId)) {
|
|
4415
|
+
return { success: false, error: this.lifecycleFailureText('close', entry, this.lifecycleRefusal(entry.serverId)), isError: true };
|
|
3266
4416
|
}
|
|
3267
4417
|
|
|
3268
4418
|
try {
|
|
3269
|
-
if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
|
|
3270
|
-
return {
|
|
3271
|
-
success: false,
|
|
3272
|
-
error: `channels.lifecycle not in "${entry.serverId}"'s effective grant (§14.1)`,
|
|
3273
|
-
isError: true,
|
|
3274
|
-
};
|
|
3275
|
-
}
|
|
3276
4419
|
const applied = await this.applyDesiredChannelState(entry.serverId, input.channelId);
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
}
|
|
4420
|
+
const now = this.getDesiredState(entry.serverId, input.channelId);
|
|
4421
|
+
if (applied.open || now !== 'closed') throw new ChannelLifecycleError(supersededClause('close', now), 'superseded');
|
|
3280
4422
|
return {
|
|
3281
4423
|
success: true,
|
|
3282
4424
|
data: { channelId: input.channelId, status: 'closed' },
|
|
3283
4425
|
};
|
|
3284
4426
|
} catch (err) {
|
|
3285
|
-
return {
|
|
3286
|
-
success: false,
|
|
3287
|
-
error: `Failed to close channel: ${(err as Error).message}`,
|
|
3288
|
-
isError: true,
|
|
3289
|
-
};
|
|
4427
|
+
return { success: false, error: this.lifecycleFailureText('close', entry, err), isError: true };
|
|
3290
4428
|
}
|
|
3291
4429
|
}
|
|
3292
4430
|
|
|
@@ -3301,81 +4439,252 @@ export class ChannelRegistry {
|
|
|
3301
4439
|
if (!entry) {
|
|
3302
4440
|
return { success: false, error: resolved.error, isError: true };
|
|
3303
4441
|
}
|
|
4442
|
+
// A tuned-out channel's decision is the tune-out, whatever its transport,
|
|
4443
|
+
// and a decline recorded over it would end it without its cancel, so the
|
|
4444
|
+
// backlog it diverted would never reach the agent. Its cancel is what ends
|
|
4445
|
+
// it, as channel_open's refusal of a tuned-out channel says.
|
|
4446
|
+
if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
|
|
4447
|
+
return tunedOutRefusal('channel_decline', input.channelId);
|
|
4448
|
+
}
|
|
3304
4449
|
if (entry.open || this.getDesiredState(entry.serverId, input.channelId) === 'open') {
|
|
4450
|
+
// channel_close is the call that closes it: its own answer says when,
|
|
4451
|
+
// as for a server that isn't up. A server that's up without that grant
|
|
4452
|
+
// keeps its channels as it chooses, so the text says so, and whom to
|
|
4453
|
+
// ask.
|
|
4454
|
+
const refusal = this.serverReach(entry.serverId) === 'up' && !this.directsLifecycle(entry.serverId)
|
|
4455
|
+
? this.lifecycleRefusal(entry.serverId) : undefined;
|
|
3305
4456
|
return {
|
|
3306
4457
|
success: false,
|
|
3307
|
-
error: `Channel
|
|
4458
|
+
error: `Channel "${input.channelId}" is open, or set to open, so there's nothing to decline` +
|
|
4459
|
+
(refusal ? `, and ${refusal.message}; if you need it closed, ask whoever runs this host.`
|
|
4460
|
+
: '. If you need it closed, call channel_close.'),
|
|
3308
4461
|
isError: true,
|
|
3309
4462
|
};
|
|
3310
4463
|
}
|
|
3311
4464
|
|
|
3312
4465
|
// The lifecycle decision is authoritative even if the optional public
|
|
3313
|
-
// acknowledgment cannot be rendered by the surface.
|
|
3314
|
-
|
|
4466
|
+
// acknowledgment cannot be rendered by the surface. One the log refused
|
|
4467
|
+
// changed nothing, and nothing after it runs.
|
|
4468
|
+
try {
|
|
4469
|
+
this.setDesiredState(entry.serverId, input.channelId, 'closed', 'invitation-declined');
|
|
4470
|
+
} catch (err) {
|
|
4471
|
+
return {
|
|
4472
|
+
success: false,
|
|
4473
|
+
error: `Nothing changed for channel "${input.channelId}": ${unrecordedDecision(err).message}, so no acknowledgment ` +
|
|
4474
|
+
'was posted and nothing was sent to the server. Calling channel_decline again tries again; if it fails the same ' +
|
|
4475
|
+
'way, ask whoever runs this host.',
|
|
4476
|
+
isError: true,
|
|
4477
|
+
};
|
|
4478
|
+
}
|
|
3315
4479
|
|
|
3316
4480
|
let acknowledged = false;
|
|
3317
4481
|
let representation: string | undefined;
|
|
3318
|
-
|
|
4482
|
+
// What became of an acknowledgment that wasn't posted, worded once the
|
|
4483
|
+
// decline's own outcome is known: each says the decline is recorded,
|
|
4484
|
+
// since it is whatever becomes of the acknowledgment, and a repeated
|
|
4485
|
+
// decline records it again and posts, but only where another decline
|
|
4486
|
+
// would be taken (`retry`).
|
|
4487
|
+
let ackFailure: ((retry: string | undefined) => string) | undefined;
|
|
3319
4488
|
if (input.acknowledge) {
|
|
3320
4489
|
const server = this.serverRegistry.getServer(entry.serverId);
|
|
3321
|
-
if (
|
|
3322
|
-
acknowledgmentError = `Server not found: ${entry.serverId}`;
|
|
3323
|
-
} else if (!CapabilityGrant.of(server).has('channels.acknowledge')) {
|
|
3324
|
-
acknowledgmentError = `channels.acknowledge not in "${entry.serverId}"'s effective grant (§14.1)`;
|
|
3325
|
-
} else {
|
|
4490
|
+
if (server && CapabilityGrant.of(server).has('channels.acknowledge')) {
|
|
3326
4491
|
try {
|
|
3327
|
-
const result = await server.sendChannelsAcknowledge({
|
|
4492
|
+
const result: unknown = await server.sendChannelsAcknowledge({
|
|
3328
4493
|
channelId: input.channelId,
|
|
3329
4494
|
messageId: input.messageId,
|
|
3330
4495
|
intent: 'seen-not-opening',
|
|
3331
4496
|
value: input.acknowledge,
|
|
3332
4497
|
});
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
4498
|
+
// An answer counts by #163's rule, as a publish's does: only
|
|
4499
|
+
// acknowledged: true confirms a post, and only false denies one.
|
|
4500
|
+
const receipt = (result ?? {}) as { acknowledged?: unknown; representation?: unknown; reason?: unknown };
|
|
4501
|
+
if (receipt.acknowledged === true) {
|
|
4502
|
+
acknowledged = true;
|
|
4503
|
+
if (typeof receipt.representation === 'string') representation = receipt.representation;
|
|
4504
|
+
} else if (receipt.acknowledged === false) {
|
|
4505
|
+
// A reason that isn't a non-empty string is none.
|
|
4506
|
+
const reason = typeof receipt.reason === 'string' && receipt.reason !== '' ? connectorText(receipt.reason) : undefined;
|
|
4507
|
+
ackFailure = (retry) => reason !== undefined
|
|
4508
|
+
? withStop(`The decline is recorded, but the "${entry.serverId}" server didn't post the acknowledgment: ${reason}`)
|
|
4509
|
+
: `The decline is recorded, but the "${entry.serverId}" server didn't post the acknowledgment, and gave no reason.` +
|
|
4510
|
+
(retry ? ` To try once more, ${retry}.` : '');
|
|
4511
|
+
} else {
|
|
4512
|
+
ackFailure = (retry) => `The decline is recorded, but the "${entry.serverId}" server's answer didn't say whether it ` +
|
|
4513
|
+
'posted the acknowledgment.' + (retry ? ' Calling channel_decline again with acknowledge could post it twice.' : '');
|
|
3338
4514
|
}
|
|
3339
4515
|
} catch (error) {
|
|
3340
|
-
|
|
4516
|
+
// Only a request that never left the host proves nothing was
|
|
4517
|
+
// posted, as for a publish (publishErrorOutcome).
|
|
4518
|
+
const words = requestErrorText(error);
|
|
4519
|
+
ackFailure = error instanceof McplRequestError && error.outcome === 'not-sent'
|
|
4520
|
+
? (retry) => withStop(`The decline is recorded, but the acknowledgment wasn't posted: ${words}`) +
|
|
4521
|
+
(retry ? ` To try again, ${retry}.` : '')
|
|
4522
|
+
: (retry) => withStop(`The decline is recorded, but whether the acknowledgment was posted isn't known: ${words}`) +
|
|
4523
|
+
(retry ? ' Calling channel_decline again with acknowledge could post it twice.' : '');
|
|
3341
4524
|
}
|
|
4525
|
+
} else {
|
|
4526
|
+
const reach = this.serverReach(entry.serverId);
|
|
4527
|
+
// Read when the acknowledgment was attempted. Where the server is up,
|
|
4528
|
+
// the decline's close can await past a change to its grant, so that
|
|
4529
|
+
// text says what kept the acknowledgment back then.
|
|
4530
|
+
ackFailure = (retry) => reach === 'up'
|
|
4531
|
+
? `The decline is recorded, but this host wasn't permitted to post acknowledgments to the "${entry.serverId}" server, ` +
|
|
4532
|
+
'so none was posted. If you need it, ask whoever runs this host.'
|
|
4533
|
+
: `The decline is recorded, but the acknowledgment wasn't posted: ${unreachableClause(entry.serverId, reach)}.` +
|
|
4534
|
+
(reach === 'gone' ? ' If you need it, ask whoever runs this host.'
|
|
4535
|
+
: retry ? ` To post it, ${retry} ${reach === 'connecting' ? 'in a moment' : "once it's back"}.` : '');
|
|
3342
4536
|
}
|
|
3343
4537
|
}
|
|
3344
4538
|
|
|
3345
4539
|
// Where the host directs the lifecycle, a decline goes through the
|
|
3346
4540
|
// lifecycle queue like any closed decision, unless a receipt confirmed
|
|
3347
|
-
// the transport closed with nothing pending: an
|
|
3348
|
-
// be open, and pending work must settle on this decision.
|
|
3349
|
-
//
|
|
3350
|
-
|
|
4541
|
+
// the transport closed with nothing pending: an open or unconfirmed
|
|
4542
|
+
// transport may be open, and pending work must settle on this decision.
|
|
4543
|
+
// The decline refused an open channel before its acknowledgment, but
|
|
4544
|
+
// traffic can show one open meanwhile where this host didn't direct the
|
|
4545
|
+
// server's channels, before a grant that now lets it. A decline acts
|
|
4546
|
+
// only on its own decision: one made meanwhile, to open the channel or
|
|
4547
|
+
// tune it out, isn't the decline's to apply. A failed close is reported
|
|
4548
|
+
// below; the decision stands.
|
|
4549
|
+
let closeFailure: unknown;
|
|
4550
|
+
let closeFailed = false;
|
|
3351
4551
|
const current = this.channels.get(`${entry.serverId}:${input.channelId}`);
|
|
3352
4552
|
if (current && this.directsLifecycle(entry.serverId) &&
|
|
3353
|
-
|
|
4553
|
+
this.getDesiredState(entry.serverId, input.channelId) === 'closed' &&
|
|
4554
|
+
(current.open || this.unconfirmedChannelTargets.has(current) || this.hasPendingLifecycle(current))) {
|
|
3354
4555
|
try {
|
|
3355
|
-
|
|
3356
|
-
if (applied.open) closeError = 'the close was superseded by a newer lifecycle decision';
|
|
4556
|
+
await this.applyDesiredChannelState(entry.serverId, input.channelId);
|
|
3357
4557
|
} catch (error) {
|
|
3358
|
-
|
|
4558
|
+
closeFailure = error;
|
|
4559
|
+
closeFailed = true;
|
|
3359
4560
|
}
|
|
3360
4561
|
}
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
4562
|
+
// A decision made while the decline ran, to open the channel or tune it
|
|
4563
|
+
// out, replaced it, and that is what stands.
|
|
4564
|
+
const decided = this.getDesiredState(entry.serverId, input.channelId);
|
|
4565
|
+
const stands = decided === 'closed';
|
|
4566
|
+
// The channel as the decline leaves it: closed for certain only where
|
|
4567
|
+
// it is registered, not open, its target confirmed, and no lifecycle
|
|
4568
|
+
// work for it outstanding, as the in-effect texts read it. A close that
|
|
4569
|
+
// didn't complete, a channel this host never confirmed closed, or a
|
|
4570
|
+
// registration that went leaves that unknown. A server without
|
|
4571
|
+
// channels.lifecycle keeps its channels as it chooses, and this host
|
|
4572
|
+
// asks it to open or close none of them, so a channel there is confirmed
|
|
4573
|
+
// closed only by the receipt of a request sent while the server had the
|
|
4574
|
+
// grant, whether the channel was registered or known only from a push.
|
|
4575
|
+
const after = this.channels.get(`${entry.serverId}:${input.channelId}`);
|
|
4576
|
+
const closed = after !== undefined && !after.open && !this.unconfirmedChannelTargets.has(after) &&
|
|
4577
|
+
!this.lifecycleOutstanding.has(this.lifecycleKey(entry.serverId, input.channelId));
|
|
4578
|
+
// `closeError` is the result's sentence for the agent: what became of
|
|
4579
|
+
// the channel, and what follows. Every status but `remained closed`
|
|
4580
|
+
// carries one, and that one none, by this tree; `status` is the word a
|
|
4581
|
+
// caller's code reads. Reconciling closes a channel decided closed when
|
|
4582
|
+
// the server next registers it (for a push's placeholder, if it ever
|
|
4583
|
+
// does), if this host directs their lifecycle then (§14.1), which
|
|
4584
|
+
// laterClause says.
|
|
4585
|
+
let closeError: string | undefined;
|
|
4586
|
+
const reach = this.serverReach(entry.serverId);
|
|
4587
|
+
const serverBack = reach === 'gone' ? ' If you need the server back, ask whoever runs this host.' : '';
|
|
4588
|
+
// What helps now with a registered channel the decline didn't leave
|
|
4589
|
+
// closed for certain, read from the state the reads above found, not
|
|
4590
|
+
// from how it came to be: whom to ask where this host doesn't direct the
|
|
4591
|
+
// server's channels, work still under way where some will carry the
|
|
4592
|
+
// decision out (outstandingWorkCarriesOut, not work a removal cancels),
|
|
4593
|
+
// and otherwise channel_close, which asks for the close now.
|
|
4594
|
+
const stepNow = (): string => !this.directsLifecycle(entry.serverId)
|
|
4595
|
+
? (reach === 'up' ? ' If you need it closed sooner, ask whoever runs this host.' : serverBack)
|
|
4596
|
+
: this.outstandingWorkCarriesOut(entry.serverId, this.lifecycleKey(entry.serverId, input.channelId))
|
|
4597
|
+
? ' Other work for it that\'s still under way applies the decline when its turn comes, so there\'s no need to ' +
|
|
4598
|
+
'decline again to close it.'
|
|
4599
|
+
: ' If you need it closed now, call channel_close.';
|
|
4600
|
+
if (!stands) {
|
|
4601
|
+
// channel_close closes it after all, whichever decision it was, while
|
|
4602
|
+
// the channel is registered; a server that's up without the grant
|
|
4603
|
+
// keeps its channels as it chooses, as the decline's refusal says.
|
|
4604
|
+
const step = after === undefined
|
|
4605
|
+
? `. It isn't registered here any more, so no channel tool can reach it until it's registered here again.${serverBack}`
|
|
4606
|
+
: reach === 'up' && !this.directsLifecycle(entry.serverId)
|
|
4607
|
+
? `, and ${this.lifecycleRefusal(entry.serverId).message}; if you need it closed, ask whoever runs this host.`
|
|
4608
|
+
: `. If you still want it closed, call channel_close.${serverBack}`;
|
|
4609
|
+
closeError = decided === 'tuned-out'
|
|
4610
|
+
? `The decline is recorded, but the channel was tuned out meanwhile, and that decision stands: its messages go to ` +
|
|
4611
|
+
`the subconscious${step}`
|
|
4612
|
+
: `The decline is recorded, but a later decision to open the channel replaced it, and that decision stands${step}`;
|
|
4613
|
+
} else if (closed) {
|
|
4614
|
+
// Closed for certain, whatever became of the decline's own close: one
|
|
4615
|
+
// refused for a grant that went after an earlier operation confirmed
|
|
4616
|
+
// the close changes nothing the agent needs.
|
|
4617
|
+
} else if (closeFailed) {
|
|
4618
|
+
// What happened comes from the failure: a registration that went
|
|
4619
|
+
// while the close was out says so, since the server may have closed it
|
|
4620
|
+
// first, and one that came back since says that; a request that failed
|
|
4621
|
+
// while the registration went says both. What helps comes from the
|
|
4622
|
+
// state now.
|
|
4623
|
+
const failure = closeFailure instanceof ChannelLifecycleError ? closeFailure : undefined;
|
|
4624
|
+
const went = failure?.kind === 'cancelled' || failure?.kind === 'unlisted';
|
|
4625
|
+
closeError = withStop(
|
|
4626
|
+
`The decline is recorded, and this host closes the channel ${laterClause(entry)}; ` +
|
|
4627
|
+
(!went ? `closing it now didn't complete: ${lifecycleClause(closeFailure)}`
|
|
4628
|
+
: after !== undefined
|
|
4629
|
+
? 'its registration went and came back while the close was out'
|
|
4630
|
+
: `it isn't registered here any more: ${failure!.message}`),
|
|
4631
|
+
) + (after === undefined ? (went ? '' : ' It isn\'t registered here any more.') + serverBack : stepNow());
|
|
4632
|
+
} else if (after === undefined) {
|
|
4633
|
+
// The registration went while the acknowledgment was out, so no close
|
|
4634
|
+
// was tried.
|
|
4635
|
+
closeError = withStop(
|
|
4636
|
+
`The decline is recorded, and this host closes the channel ${laterClause(entry)}; it isn't registered here any more` +
|
|
4637
|
+
(reach !== 'up' ? `: ${unreachableClause(entry.serverId, reach)}` : ''),
|
|
4638
|
+
) + serverBack;
|
|
4639
|
+
} else if (!this.directsLifecycle(entry.serverId)) {
|
|
4640
|
+
closeError = reach === 'up'
|
|
4641
|
+
? `The decline is recorded, but ${this.lifecycleRefusal(entry.serverId).message}, so the server keeps this one open ` +
|
|
4642
|
+
`or closed as it chooses, and declining again won't change that. This host closes it ${laterClause(entry)}. If you ` +
|
|
4643
|
+
'need it closed sooner, ask whoever runs this host.'
|
|
4644
|
+
: `The decline is recorded, but ${unreachableClause(entry.serverId, reach)}, so the channel may not be closed there ` +
|
|
4645
|
+
`yet. This host closes it ${laterClause(entry)}, so there's no need to decline again to close it.` + serverBack;
|
|
4646
|
+
} else {
|
|
4647
|
+
// This host directs the channel's lifecycle and its close didn't fail,
|
|
4648
|
+
// so what remains is other work for it, or a change since that close's
|
|
4649
|
+
// receipt that left it unconfirmed; the step says which.
|
|
4650
|
+
closeError = 'The decline is recorded, but the channel isn\'t closed for certain yet.' + stepNow();
|
|
4651
|
+
}
|
|
4652
|
+
// Another decline is taken only where this one was: the channel
|
|
4653
|
+
// registered, not open, and not decided open.
|
|
4654
|
+
const again = stands && after !== undefined && !after.open;
|
|
4655
|
+
const acknowledgmentError = ackFailure?.(again ? 'call channel_decline again with the same arguments' : undefined);
|
|
4656
|
+
// What the decline found, in a word: the channel remained closed, the
|
|
4657
|
+
// decline stands though the channel may not be closed, or a later
|
|
4658
|
+
// decision replaced it.
|
|
4659
|
+
const status = !stands ? 'replaced' : closed ? 'remained closed' : 'declined';
|
|
4660
|
+
|
|
4661
|
+
// An audit record nothing reads back: the decline took effect above, so
|
|
4662
|
+
// a log that refuses it is the operator's to hear about, not a failure
|
|
4663
|
+
// of the decline.
|
|
4664
|
+
try {
|
|
4665
|
+
this.appendLifecycleEvent({
|
|
4666
|
+
kind: 'invitation-declined',
|
|
4667
|
+
serverId: entry.serverId,
|
|
4668
|
+
channelId: input.channelId,
|
|
4669
|
+
messageId: input.messageId,
|
|
4670
|
+
acknowledgment: representation ?? input.acknowledge,
|
|
4671
|
+
timestamp: new Date().toISOString(),
|
|
4672
|
+
});
|
|
4673
|
+
} catch (err) {
|
|
4674
|
+
console.error(
|
|
4675
|
+
`[channel-decline] server=${entry.serverId} channel=${logValue(input.channelId)}: the declined invitation wasn't ` +
|
|
4676
|
+
`recorded: ${err instanceof Error ? err.message : String(err)}`,
|
|
4677
|
+
);
|
|
4678
|
+
}
|
|
3370
4679
|
return {
|
|
3371
4680
|
success: true,
|
|
3372
4681
|
data: {
|
|
3373
4682
|
channelId: input.channelId,
|
|
3374
|
-
status
|
|
4683
|
+
status,
|
|
3375
4684
|
acknowledged,
|
|
3376
4685
|
...(representation ? { representation } : {}),
|
|
3377
4686
|
...(acknowledgmentError ? { acknowledgmentError } : {}),
|
|
3378
|
-
...(closeError ? { closeError
|
|
4687
|
+
...(closeError ? { closeError } : {}),
|
|
3379
4688
|
},
|
|
3380
4689
|
};
|
|
3381
4690
|
}
|
|
@@ -3463,53 +4772,83 @@ export class ChannelRegistry {
|
|
|
3463
4772
|
}
|
|
3464
4773
|
|
|
3465
4774
|
/**
|
|
3466
|
-
*
|
|
3467
|
-
*
|
|
3468
|
-
*
|
|
3469
|
-
*
|
|
3470
|
-
*
|
|
3471
|
-
* is
|
|
3472
|
-
*
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
*
|
|
3476
|
-
*
|
|
3477
|
-
|
|
3478
|
-
*
|
|
3479
|
-
*
|
|
3480
|
-
*
|
|
3481
|
-
*
|
|
3482
|
-
*
|
|
3483
|
-
*
|
|
4775
|
+
* MCPL Spec 14.3 outgoing streaming: forward a text delta to the server
|
|
4776
|
+
* owning the channel. The framework sends each piece of prose once its
|
|
4777
|
+
* publish succeeds, so a stream carries only what was published.
|
|
4778
|
+
* Emitted only when that server's grant has `channels.streaming` —
|
|
4779
|
+
* servers that never opted in receive nothing. Fire-and-forget and never
|
|
4780
|
+
* throws: streaming is an observer surface; the authoritative delivery is
|
|
4781
|
+
* channels/publish.
|
|
4782
|
+
*
|
|
4783
|
+
* `to` is the destination the publish went to, its server and channel.
|
|
4784
|
+
* The chunk goes there while that server registers that channel (Spec
|
|
4785
|
+
* 14.3: the recipient is "the server that registered the target
|
|
4786
|
+
* channel"). A stream doesn't start in a channel its server has declared
|
|
4787
|
+
* inbound-only (#350's rule), not even for a publish delivered there just
|
|
4788
|
+
* before the declaration. One that started there before it goes on, since
|
|
4789
|
+
* the declaration doesn't unregister the channel: a later chunk is for a
|
|
4790
|
+
* publish delivered before it. A bare channel id is resolved as before, to
|
|
4791
|
+
* the one server that registers it, at each call (streamingServerFor), and
|
|
4792
|
+
* keeps no record: a stream sent by destination ends by destination, since
|
|
4793
|
+
* only a destination's `complete` removes its record.
|
|
3484
4794
|
*/
|
|
3485
4795
|
sendOutgoingChunk(
|
|
3486
|
-
|
|
4796
|
+
to: string | PublishDestination,
|
|
3487
4797
|
conversationId: string,
|
|
3488
4798
|
inferenceId: string,
|
|
3489
4799
|
index: number,
|
|
3490
4800
|
delta: string,
|
|
3491
4801
|
): void {
|
|
3492
|
-
|
|
4802
|
+
let server: McplServerConnection | null;
|
|
4803
|
+
if (typeof to === 'string') {
|
|
4804
|
+
server = this.streamingServerFor(to);
|
|
4805
|
+
} else {
|
|
4806
|
+
const key = destinationKey(to);
|
|
4807
|
+
let started = this.takenStreams.get(inferenceId);
|
|
4808
|
+
const continuing = started?.has(key) ?? false;
|
|
4809
|
+
server = this.streamingServerAt(to, !continuing);
|
|
4810
|
+
if (server && !continuing) {
|
|
4811
|
+
if (!started) this.takenStreams.set(inferenceId, started = new Set());
|
|
4812
|
+
started.add(key);
|
|
4813
|
+
}
|
|
4814
|
+
}
|
|
3493
4815
|
if (!server) return;
|
|
4816
|
+
const channelId = typeof to === 'string' ? to : to.channelId;
|
|
3494
4817
|
try {
|
|
3495
4818
|
server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId, index, delta });
|
|
3496
4819
|
} catch { /* observer surface — never disturb the turn */ }
|
|
3497
4820
|
}
|
|
3498
4821
|
|
|
3499
|
-
/**
|
|
4822
|
+
/**
|
|
4823
|
+
* Spec 14.3 companion: final moderated content per channel at stream end.
|
|
4824
|
+
* Given a destination, it goes only where the stream started
|
|
4825
|
+
* (sendOutgoingChunk), while that server still registers that channel,
|
|
4826
|
+
* inbound-only or not, and its grant has `channels.streaming`: once per
|
|
4827
|
+
* destination, whose consumer, such as a TTS utterance, finalizes on it
|
|
4828
|
+
* and reconciles any chunk it missed. A bare channel id is resolved as
|
|
4829
|
+
* before (streamingServerFor).
|
|
4830
|
+
*/
|
|
3500
4831
|
sendOutgoingComplete(
|
|
3501
|
-
|
|
4832
|
+
to: string | PublishDestination,
|
|
3502
4833
|
conversationId: string,
|
|
3503
4834
|
inferenceId: string,
|
|
3504
4835
|
text: string,
|
|
3505
4836
|
): void {
|
|
3506
|
-
|
|
4837
|
+
let server: McplServerConnection | null;
|
|
4838
|
+
if (typeof to === 'string') {
|
|
4839
|
+
server = this.streamingServerFor(to);
|
|
4840
|
+
} else {
|
|
4841
|
+
const started = this.takenStreams.get(inferenceId);
|
|
4842
|
+
if (!started?.delete(destinationKey(to))) return;
|
|
4843
|
+
if (started.size === 0) this.takenStreams.delete(inferenceId);
|
|
4844
|
+
server = this.streamingServerAt(to, false);
|
|
4845
|
+
}
|
|
3507
4846
|
if (!server) return;
|
|
3508
4847
|
try {
|
|
3509
4848
|
server.sendChannelsOutgoingComplete({
|
|
3510
4849
|
inferenceId,
|
|
3511
4850
|
conversationId,
|
|
3512
|
-
channelId,
|
|
4851
|
+
channelId: typeof to === 'string' ? to : to.channelId,
|
|
3513
4852
|
content: [{ type: 'text', text }],
|
|
3514
4853
|
});
|
|
3515
4854
|
} catch { /* observer surface — never disturb the turn */ }
|
|
@@ -3522,6 +4861,9 @@ export class ChannelRegistry {
|
|
|
3522
4861
|
// never-guess rule that governs delivery.
|
|
3523
4862
|
const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
|
|
3524
4863
|
if (matches.length !== 1) return null;
|
|
4864
|
+
// Nothing is published into an inbound-only channel, so nothing is
|
|
4865
|
+
// streamed there either.
|
|
4866
|
+
if (isInboundOnly(matches[0]!)) return null;
|
|
3525
4867
|
const server = this.serverRegistry.getServer(matches[0]!.serverId);
|
|
3526
4868
|
// §5.4: the GRANT gates streaming, not the raw advertisement. The old
|
|
3527
4869
|
// `capabilities?.channels?.streaming` check was doubly wrong: undefined
|
|
@@ -3532,6 +4874,24 @@ export class ChannelRegistry {
|
|
|
3532
4874
|
return server;
|
|
3533
4875
|
}
|
|
3534
4876
|
|
|
4877
|
+
/**
|
|
4878
|
+
* A destination's server, for a stream message: while that server
|
|
4879
|
+
* registers that channel, and its grant has `channels.streaming` (as in
|
|
4880
|
+
* streamingServerFor). A stream doesn't start in an inbound-only channel.
|
|
4881
|
+
*/
|
|
4882
|
+
private streamingServerAt(to: PublishDestination, starting: boolean): McplServerConnection | null {
|
|
4883
|
+
// The map's key joins the two ids with a colon, so another server's
|
|
4884
|
+
// entry can sit under a destination's key: it has to be this server's.
|
|
4885
|
+
const entry = this.channels.get(`${to.serverId}:${to.channelId}`);
|
|
4886
|
+
if (!entry || entry.serverId !== to.serverId) return null;
|
|
4887
|
+
if (starting && isInboundOnly(entry)) return null;
|
|
4888
|
+
const server = this.serverRegistry.getServer(to.serverId);
|
|
4889
|
+
return CapabilityGrant.of(server).has('channels.streaming') ? server : null;
|
|
4890
|
+
}
|
|
4891
|
+
|
|
4892
|
+
/** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel →
|
|
4893
|
+
* process-global default). Public so a multi-segment caller can snapshot it
|
|
4894
|
+
* ONCE and pin every segment to it via routeSpeech's `overrideChannelId`. */
|
|
3535
4895
|
resolveLocus(conversationId: string): string | null {
|
|
3536
4896
|
const home = this.homeChannelResolver?.(conversationId);
|
|
3537
4897
|
return home ?? this.activeChannelResolver?.(conversationId) ?? this.globalFallbackFor(conversationId);
|
|
@@ -3579,13 +4939,17 @@ export class ChannelRegistry {
|
|
|
3579
4939
|
if (matches.length === 0) {
|
|
3580
4940
|
return {
|
|
3581
4941
|
error: target.serverId
|
|
3582
|
-
? `
|
|
3583
|
-
: `no
|
|
4942
|
+
? `the "${target.serverId}" server has no channel "${target.channelId}" registered here`
|
|
4943
|
+
: `no channel "${target.channelId}" is registered here`,
|
|
3584
4944
|
};
|
|
3585
4945
|
}
|
|
3586
4946
|
if (matches.length > 1) {
|
|
3587
4947
|
return {
|
|
3588
|
-
|
|
4948
|
+
// Speech names no server, and neither its >> prefix nor
|
|
4949
|
+
// channel_publish can: whoever runs this host can. A caller that
|
|
4950
|
+
// can name one reads that none was.
|
|
4951
|
+
error: `channel id "${target.channelId}" is on more than one server, and no server was named for these words; ` +
|
|
4952
|
+
'if you need them there, ask whoever runs this host',
|
|
3589
4953
|
};
|
|
3590
4954
|
}
|
|
3591
4955
|
return { entry: matches[0]! };
|
|
@@ -3608,7 +4972,33 @@ export class ChannelRegistry {
|
|
|
3608
4972
|
* possible to send into a closed channel: a closed but registered
|
|
3609
4973
|
* destination is opened first (`openSource` names why), once its server
|
|
3610
4974
|
* and that server's channels.publish grant are known to take the send, so
|
|
3611
|
-
* a refused
|
|
4975
|
+
* a send refused before the open opens nothing; and a failed open sends
|
|
4976
|
+
* nothing.
|
|
4977
|
+
*
|
|
4978
|
+
* A channel its server declared inbound-only (isInboundOnly) takes nothing
|
|
4979
|
+
* the host would post, whichever path the words took, a drafts resend
|
|
4980
|
+
* included: it's refused before anything opens. The channel's queued
|
|
4981
|
+
* lifecycle work and the open await the server, and a channels/changed
|
|
4982
|
+
* update, a re-register or the open's own answer (applyOpenDeclaration)
|
|
4983
|
+
* may declare the channel inbound-only meanwhile, or a removal take it
|
|
4984
|
+
* away. So the check just before the publish, after every await, reads
|
|
4985
|
+
* the channel's registration as it is then, and refuses one that is gone
|
|
4986
|
+
* or inbound-only, or whose server's grant no longer has channels.publish.
|
|
4987
|
+
* A removal during a wait already fails it there (waitForOpenChannel); the
|
|
4988
|
+
* check also catches one made after the last wait, as the opening's notice
|
|
4989
|
+
* can. A registration nothing has confirmed open, such as one that
|
|
4990
|
+
* replaced the one waited for or a retarget of it, or one whose close now
|
|
4991
|
+
* stands (where the host directs the server's lifecycle), is treated as at
|
|
4992
|
+
* the first wait: the words wait for its queued lifecycle work, and the
|
|
4993
|
+
* check runs again on the registration that work leaves. Words refused
|
|
4994
|
+
* after an open for a declaration leave the channel open, and the agent
|
|
4995
|
+
* gets the failure for the words. An open by delivery (`openSource`
|
|
4996
|
+
* 'opened-by-delivery', the default) that turned a closed channel open
|
|
4997
|
+
* also gives the notice that it's open, which says how to close it to a
|
|
4998
|
+
* reader that can.
|
|
4999
|
+
* One that only reconfirmed the transport of a channel already decided
|
|
5000
|
+
* open or tuned out changes no decision, and gives none. When the
|
|
5001
|
+
* registration is gone, the failure says the channel was removed.
|
|
3612
5002
|
*
|
|
3613
5003
|
* Only the connector's `delivered: true` confirms a post. `failed` means
|
|
3614
5004
|
* nothing was posted: the attempt stopped before the request was written,
|
|
@@ -3645,20 +5035,19 @@ export class ChannelRegistry {
|
|
|
3645
5035
|
|
|
3646
5036
|
// A send that would be refused is refused before anything opens: a
|
|
3647
5037
|
// closed channel opens only for words that can go out.
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
return { status: 'failed', destination, reason: `server "${entry.serverId}" not found`, at: at() };
|
|
5038
|
+
if (isInboundOnly(entry)) {
|
|
5039
|
+
return { status: 'failed', destination, reason: inboundOnlyReason(entry), at: at() };
|
|
3651
5040
|
}
|
|
5041
|
+
const server = this.serverRegistry.getServer(entry.serverId);
|
|
3652
5042
|
// §14.1: channels/publish requires channels.publish in the grant. The
|
|
3653
|
-
// host not sending is the enforcement
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
}
|
|
5043
|
+
// host not sending is the enforcement, here and at the last check, and
|
|
5044
|
+
// the server's state words the refusal (sendRefusal).
|
|
5045
|
+
const ungranted = (): PublishOutcome => ({ status: 'failed', destination, reason: this.sendRefusal(entry.serverId), at: at() });
|
|
5046
|
+
if (!server || !CapabilityGrant.of(server).has('channels.publish')) return ungranted();
|
|
5047
|
+
|
|
5048
|
+
const prevented = (err: unknown): PublishOutcome => ({
|
|
5049
|
+
status: 'failed', destination, reason: lifecycleClause(err) + refusalRemedy(err), at: at(),
|
|
5050
|
+
});
|
|
3662
5051
|
|
|
3663
5052
|
// Lifecycle work already queued for the channel settles first: words go
|
|
3664
5053
|
// out only into the channel it leaves, and fail visibly if it leaves
|
|
@@ -3667,12 +5056,7 @@ export class ChannelRegistry {
|
|
|
3667
5056
|
try {
|
|
3668
5057
|
entry = await this.waitForOpenChannel(entry);
|
|
3669
5058
|
} catch (err) {
|
|
3670
|
-
return
|
|
3671
|
-
status: 'failed',
|
|
3672
|
-
destination,
|
|
3673
|
-
reason: `lifecycle work prevented delivery: ${connectorText(err instanceof Error ? err.message : String(err))}`,
|
|
3674
|
-
at: at(),
|
|
3675
|
-
};
|
|
5059
|
+
return prevented(err);
|
|
3676
5060
|
}
|
|
3677
5061
|
}
|
|
3678
5062
|
|
|
@@ -3717,15 +5101,45 @@ export class ChannelRegistry {
|
|
|
3717
5101
|
}
|
|
3718
5102
|
}
|
|
3719
5103
|
} catch (err) {
|
|
3720
|
-
return {
|
|
3721
|
-
status: 'failed',
|
|
3722
|
-
destination,
|
|
3723
|
-
reason: `channel is closed and open failed: ${connectorText(err instanceof Error ? err.message : String(err))}`,
|
|
3724
|
-
at: at(),
|
|
3725
|
-
};
|
|
5104
|
+
return { status: 'failed', destination, reason: openFailedReason(err), at: at() };
|
|
3726
5105
|
}
|
|
3727
5106
|
}
|
|
3728
5107
|
|
|
5108
|
+
// Checked again here, after every await above, the lifecycle work's, the
|
|
5109
|
+
// open's or any other: a registration that is gone, or inbound-only, at
|
|
5110
|
+
// this moment takes nothing. The entry is read from the map rather than
|
|
5111
|
+
// taken from the last wait: the opening's notice runs in between, and
|
|
5112
|
+
// publish() resumes a microtask after the wait. A registration nothing
|
|
5113
|
+
// has confirmed open, such as one that replaced the one waited for
|
|
5114
|
+
// (registered with open: false) or a retarget of it (marked unconfirmed),
|
|
5115
|
+
// or one whose close now stands (closeStands), is treated as at the
|
|
5116
|
+
// first wait: the words wait for its queued lifecycle work, and
|
|
5117
|
+
// waitForOpenChannel refuses what that work leaves unconfirmed or with
|
|
5118
|
+
// its close standing. The loop opens nothing itself. The check then runs
|
|
5119
|
+
// again on the registration that stands. waitForOpenChannel returns
|
|
5120
|
+
// only a registration it found open and whose close doesn't stand, or
|
|
5121
|
+
// throws, so the loop ends unless the channel keeps changing.
|
|
5122
|
+
const key = `${entry.serverId}:${entry.descriptor.id}`;
|
|
5123
|
+
let current = this.channels.get(key);
|
|
5124
|
+
while (current && !isInboundOnly(current) && (!current.open || this.closeStands(current))) {
|
|
5125
|
+
try {
|
|
5126
|
+
entry = await this.waitForOpenChannel(current);
|
|
5127
|
+
} catch (err) {
|
|
5128
|
+
return prevented(err);
|
|
5129
|
+
}
|
|
5130
|
+
current = this.channels.get(key);
|
|
5131
|
+
}
|
|
5132
|
+
if (!current) return { status: 'failed', destination, reason: CHANNEL_REMOVED_CLAUSE, at: at() };
|
|
5133
|
+
if (isInboundOnly(current)) {
|
|
5134
|
+
return { status: 'failed', destination, reason: inboundOnlyReason(current), at: at() };
|
|
5135
|
+
}
|
|
5136
|
+
// A grant reduced during any of the waits holds at once (§6.7): the
|
|
5137
|
+
// words already on their way take it too. Its refusal is worded for the
|
|
5138
|
+
// connection that refused (sendRefusal's `checked`).
|
|
5139
|
+
if (!CapabilityGrant.of(server).has('channels.publish')) {
|
|
5140
|
+
return { status: 'failed', destination, reason: this.sendRefusal(entry.serverId, server), at: at() };
|
|
5141
|
+
}
|
|
5142
|
+
|
|
3729
5143
|
let result: unknown;
|
|
3730
5144
|
try {
|
|
3731
5145
|
result = await server.sendChannelsPublish({
|
|
@@ -3743,9 +5157,13 @@ export class ChannelRegistry {
|
|
|
3743
5157
|
* Plain speech for one turn segment: publish `text` to the turn's frozen
|
|
3744
5158
|
* locus and report the PublishOutcome. A failure (or an uncertain outcome)
|
|
3745
5159
|
* is surfaced as a trace and through onRouteFailure, so the agent learns
|
|
3746
|
-
* its words did not (or may not have) reached anyone.
|
|
3747
|
-
*
|
|
3748
|
-
*
|
|
5160
|
+
* its words did not (or may not have) reached anyone. A `failed` one whose
|
|
5161
|
+
* caller holds its words with a notice of its own (`heldOnFailure`) gets
|
|
5162
|
+
* the trace but not onRouteFailure. An ephemeral run's speech with no locus
|
|
5163
|
+
* (null) is its caller's result: it returns as not delivered and surfaces
|
|
5164
|
+
* nothing. This is host-owned output routing (see forking-knowledge-miner
|
|
5165
|
+
* LOCUS-ROUTING-DESIGN), which replaced the per-surface sticky auto-post
|
|
5166
|
+
* that used to live in discord-mcpl.
|
|
3749
5167
|
*/
|
|
3750
5168
|
async deliverSpeech(
|
|
3751
5169
|
conversationId: string,
|
|
@@ -3765,16 +5183,18 @@ export class ChannelRegistry {
|
|
|
3765
5183
|
* An `unknown` outcome still gets its marker. */
|
|
3766
5184
|
opts?: { heldOnFailure?: boolean },
|
|
3767
5185
|
): Promise<PublishOutcome> {
|
|
3768
|
-
|
|
5186
|
+
// `logged` is what the log line and the trace say, where it isn't the
|
|
5187
|
+
// agent's reason (the two no-locus reasons below).
|
|
5188
|
+
const fail = (channelId: string | null, reason: string, outcome: PublishOutcome, logged = reason): PublishOutcome => {
|
|
3769
5189
|
const status = outcome.status === 'unknown' ? 'unknown' : 'failed';
|
|
3770
5190
|
const detail = outcome.detail !== undefined ? connectorText(outcome.detail) : undefined;
|
|
3771
5191
|
const detailText = detail !== undefined ? `; connector detail: ${detail}` : '';
|
|
3772
|
-
console.error(`[routeSpeech] ${conversationId}: ${
|
|
5192
|
+
console.error(`[routeSpeech] ${conversationId}: ${logged}${detailText} — speech ${status === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`);
|
|
3773
5193
|
this.emitTraceFn({
|
|
3774
5194
|
type: 'mcpl:speech-route-failed',
|
|
3775
5195
|
conversationId,
|
|
3776
5196
|
channelId: channelId ?? '',
|
|
3777
|
-
reason,
|
|
5197
|
+
reason: logged,
|
|
3778
5198
|
textLen: text.length,
|
|
3779
5199
|
outcome: status,
|
|
3780
5200
|
...(detail !== undefined ? { detail } : {}),
|
|
@@ -3785,12 +5205,19 @@ export class ChannelRegistry {
|
|
|
3785
5205
|
return outcome;
|
|
3786
5206
|
};
|
|
3787
5207
|
|
|
5208
|
+
// These two reasons are the only ones whose log line and trace keep the
|
|
5209
|
+
// operator's words rather than the agent's: connectome-host's
|
|
5210
|
+
// connectome-doctor reads the last [routeSpeech] line for "no locus". Keep
|
|
5211
|
+
// "no locus" in both logged texts. Each names no tool: whether one the
|
|
5212
|
+
// agent may call could post the words isn't known here, and the notice
|
|
5213
|
+
// calls it a routing or configuration situation.
|
|
3788
5214
|
// Runtime backstop for JS callers the compiler can't see: an omitted
|
|
3789
5215
|
// locus is a routing bug at the call site, never something to paper over
|
|
3790
5216
|
// with a live re-resolution.
|
|
3791
5217
|
if (locusChannelId === undefined) {
|
|
3792
|
-
const reason = '
|
|
3793
|
-
return fail(null, reason, { status: 'failed', reason, at: Date.now() }
|
|
5218
|
+
const reason = 'the host lost track of which channel this reply was for; if this keeps happening, ask whoever runs this host';
|
|
5219
|
+
return fail(null, reason, { status: 'failed', reason, at: Date.now() },
|
|
5220
|
+
'caller passed no locus (routing bug: every speech path must snapshot the turn locus)');
|
|
3794
5221
|
}
|
|
3795
5222
|
if (!locusChannelId) {
|
|
3796
5223
|
// An ephemeral run with no channel of its own: its plain speech is its
|
|
@@ -3802,8 +5229,11 @@ export class ChannelRegistry {
|
|
|
3802
5229
|
// The turn froze with no locus (no home, no triggering channel, no
|
|
3803
5230
|
// global inbound ever seen) — the agent was told its prose stays in
|
|
3804
5231
|
// the archive; honor that.
|
|
3805
|
-
|
|
3806
|
-
|
|
5232
|
+
// The locus froze at the turn's start, so this says what the turn had.
|
|
5233
|
+
const reason = 'this turn came from no channel, and it had no home or recent channel to reply in; if it should have ' +
|
|
5234
|
+
'had one, ask whoever runs this host';
|
|
5235
|
+
return fail(null, reason, { status: 'failed', reason, at: Date.now() },
|
|
5236
|
+
'turn has no locus (no home/trigger channel; nothing to deliver into)');
|
|
3807
5237
|
}
|
|
3808
5238
|
|
|
3809
5239
|
const outcome = await this.publish(conversationId, text, { channelId: locusChannelId, routed: true });
|
|
@@ -3834,69 +5264,92 @@ export class ChannelRegistry {
|
|
|
3834
5264
|
conversationId: string,
|
|
3835
5265
|
text: string,
|
|
3836
5266
|
locusChannelId: string | null,
|
|
3837
|
-
): Promise<{ delivered: boolean; channelId: string; messageId?: string } | null> {
|
|
5267
|
+
): Promise<{ delivered: boolean; serverId: string; channelId: string; messageId?: string } | null> {
|
|
3838
5268
|
const outcome = await this.deliverSpeech(conversationId, text, locusChannelId);
|
|
3839
5269
|
if (outcome.status !== 'delivered') return null;
|
|
3840
5270
|
return {
|
|
3841
5271
|
delivered: true,
|
|
5272
|
+
serverId: outcome.destination!.serverId,
|
|
3842
5273
|
channelId: outcome.destination!.channelId,
|
|
3843
5274
|
...(outcome.messageId !== undefined ? { messageId: outcome.messageId } : {}),
|
|
3844
5275
|
};
|
|
3845
5276
|
}
|
|
3846
5277
|
|
|
5278
|
+
/**
|
|
5279
|
+
* channel_publish, and the subconscious's speak_in_channel
|
|
5280
|
+
* (publishForAgent), which shares its delivery. Where an agent finds a
|
|
5281
|
+
* channel id differs: the subconscious isn't offered channel_list, and is
|
|
5282
|
+
* given speak_in_channel for the tuned-out channels whose messages it reads.
|
|
5283
|
+
*/
|
|
3847
5284
|
private async handleToolPublish(
|
|
3848
5285
|
input: { channelId?: string; content?: string; text?: string },
|
|
3849
5286
|
origin?: ChannelToolOrigin,
|
|
5287
|
+
/** Filled for publishForAgent: the publish's outcome once a request is
|
|
5288
|
+
* attempted, for its trace, or which check of the destination refused
|
|
5289
|
+
* it before anything went out, for its answer. */
|
|
5290
|
+
seen?: { outcome?: PublishOutcome; refusal?: PublishRefusalReason },
|
|
5291
|
+
tool: 'channel_publish' | 'speak_in_channel' = 'channel_publish',
|
|
3850
5292
|
): Promise<ToolResult> {
|
|
3851
|
-
|
|
5293
|
+
const findIds = tool === 'channel_publish'
|
|
5294
|
+
? 'channel_list shows the channel ids.'
|
|
5295
|
+
: 'Use the id of a tuned-out channel whose messages you are reading.';
|
|
5296
|
+
// Resolve content: accept both `content` and `text` (backward compat).
|
|
5297
|
+
// speak_in_channel checks its own text first, so only channel_publish
|
|
5298
|
+
// reads this refusal.
|
|
3852
5299
|
const messageText = input.content ?? input.text;
|
|
3853
5300
|
if (!messageText) {
|
|
3854
|
-
return {
|
|
3855
|
-
success: false,
|
|
3856
|
-
error: 'Either content or text parameter is required',
|
|
3857
|
-
isError: true,
|
|
3858
|
-
};
|
|
5301
|
+
return { success: false, error: 'Nothing was posted: channel_publish needs the message in `content`.', isError: true };
|
|
3859
5302
|
}
|
|
3860
5303
|
|
|
3861
|
-
//
|
|
3862
|
-
//
|
|
5304
|
+
// An empty channelId names nothing, and isn't read as one left out.
|
|
5305
|
+
// speak_in_channel's caller passes one left out as empty, so there it
|
|
5306
|
+
// gets the refusal for one left out, as speak_in_channel has no default.
|
|
5307
|
+
if (input.channelId === '' && tool === 'channel_publish') {
|
|
5308
|
+
return { success: false, error: `Nothing was posted: channelId is empty. ${findIds}`, isError: true };
|
|
5309
|
+
}
|
|
5310
|
+
// Resolve channelId: default to the channel of the most recent incoming
|
|
5311
|
+
// message that isn't a reaction, reaction removal, edit or deletion
|
|
5312
|
+
// marker, apart from an edit the coalescer delivers on acceptance as the
|
|
5313
|
+
// agent's first view. An ephemeral run never takes that default
|
|
5314
|
+
// (takesGlobalFallback), so it names one.
|
|
3863
5315
|
const caller = origin?.kind === 'agent' ? origin.agentName : undefined;
|
|
3864
5316
|
const channelId = input.channelId ?? (caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel);
|
|
3865
5317
|
if (!channelId) {
|
|
3866
5318
|
return {
|
|
3867
5319
|
success: false,
|
|
3868
|
-
error:
|
|
3869
|
-
?
|
|
3870
|
-
|
|
3871
|
-
|
|
5320
|
+
error: tool === 'speak_in_channel'
|
|
5321
|
+
? `Nothing was posted: speak_in_channel needs a \`channelId\`. ${findIds}`
|
|
5322
|
+
: caller !== undefined && this.takesGlobalFallback?.(caller) === false
|
|
5323
|
+
? 'Nothing was posted: name the channel with channelId. A subagent has no default channel, since its plain '
|
|
5324
|
+
+ 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.'
|
|
5325
|
+
: `Nothing was posted: channel_publish needs a \`channelId\`, and there's no channel to default to yet. ${findIds}`,
|
|
3872
5326
|
isError: true,
|
|
3873
5327
|
};
|
|
3874
5328
|
}
|
|
3875
5329
|
|
|
3876
5330
|
const entry = this.findChannelEntry(channelId);
|
|
3877
5331
|
if (!entry) {
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
error: `Channel not found: ${channelId}`,
|
|
3881
|
-
isError: true,
|
|
3882
|
-
};
|
|
5332
|
+
if (seen) seen.refusal = 'channel-not-registered';
|
|
5333
|
+
return { success: false, error: `No channel has the id "${channelId}", so nothing was posted. ${findIds}`, isError: true };
|
|
3883
5334
|
}
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
if (!server) {
|
|
5335
|
+
if (isInboundOnly(entry)) {
|
|
5336
|
+
if (seen) seen.refusal = 'inbound-only';
|
|
3887
5337
|
return {
|
|
3888
5338
|
success: false,
|
|
3889
|
-
error: `
|
|
5339
|
+
error: `Channel ${channelId} is ${inboundOnlyReason(entry)}`,
|
|
3890
5340
|
isError: true,
|
|
3891
5341
|
};
|
|
3892
5342
|
}
|
|
3893
5343
|
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
5344
|
+
// The same refusal, in the same words, as plain speech's (sendRefusal),
|
|
5345
|
+
// whose server state is the reason's: nothing awaits between the two.
|
|
5346
|
+
const server = this.serverRegistry.getServer(entry.serverId);
|
|
5347
|
+
if (!server || !CapabilityGrant.of(server).has('channels.publish')) {
|
|
5348
|
+
if (seen) {
|
|
5349
|
+
const reach = this.serverReach(entry.serverId);
|
|
5350
|
+
seen.refusal = reach === 'up' ? 'not-permitted' : `server-${reach}`;
|
|
5351
|
+
}
|
|
5352
|
+
return { success: false, error: `Nothing was posted to channel "${channelId}": ${this.sendRefusal(entry.serverId)}.`, isError: true };
|
|
3900
5353
|
}
|
|
3901
5354
|
const publishParams: ChannelsPublishParams = {
|
|
3902
5355
|
conversationId: '', // Framework will fill this when wired
|
|
@@ -3918,15 +5371,19 @@ export class ChannelRegistry {
|
|
|
3918
5371
|
outcome = publishReceiptOutcome(result, destination, Date.now());
|
|
3919
5372
|
} catch (err) {
|
|
3920
5373
|
outcome = publishErrorOutcome(err, destination, Date.now());
|
|
5374
|
+
// Refused before anything was written: nothing went out, as its words
|
|
5375
|
+
// say, so it's a refusal of the destination like the checks above.
|
|
5376
|
+
if (seen && err instanceof McplRequestError && err.outcome === 'not-sent') seen.refusal = 'not-sent';
|
|
3921
5377
|
}
|
|
5378
|
+
if (seen) seen.outcome = outcome;
|
|
3922
5379
|
if (outcome.status === 'delivered') return { success: true, data: result };
|
|
3923
5380
|
// The connector's data may say what a multi-part send already posted.
|
|
3924
5381
|
const detail = outcome.status === 'unknown' && outcome.detail !== undefined ? ` Connector detail: ${connectorText(outcome.detail)}` : '';
|
|
3925
5382
|
return {
|
|
3926
5383
|
success: false,
|
|
3927
5384
|
error: (outcome.status === 'failed'
|
|
3928
|
-
? `
|
|
3929
|
-
: `
|
|
5385
|
+
? `Nothing was posted to channel "${channelId}": ${outcome.reason}.`
|
|
5386
|
+
: `Posting to channel "${channelId}" wasn't confirmed: ${outcome.reason}. ` +
|
|
3930
5387
|
'It may or may not have been posted, so sending it again could post it twice.') + detail,
|
|
3931
5388
|
isError: true,
|
|
3932
5389
|
};
|