@animalabs/agent-framework 0.26.2 → 0.28.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 +99 -8
- package/dist/src/agent.d.ts +291 -23
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +596 -61
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +5 -4
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/auto-continue.d.ts +34 -0
- package/dist/src/auto-continue.d.ts.map +1 -0
- package/dist/src/auto-continue.js +36 -0
- package/dist/src/auto-continue.js.map +1 -0
- package/dist/src/code-execution/isolate/scripts.d.ts +11 -0
- package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
- package/dist/src/code-execution/isolate/scripts.js +5 -1
- package/dist/src/code-execution/isolate/scripts.js.map +1 -1
- package/dist/src/cut-off-round.d.ts +24 -0
- package/dist/src/cut-off-round.d.ts.map +1 -0
- package/dist/src/cut-off-round.js +87 -0
- package/dist/src/cut-off-round.js.map +1 -0
- package/dist/src/framework.d.ts +1038 -193
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +5967 -1514
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +405 -46
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +2646 -399
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/index.d.ts +3 -2
- package/dist/src/gate/index.d.ts.map +1 -1
- package/dist/src/gate/index.js +2 -1
- package/dist/src/gate/index.js.map +1 -1
- package/dist/src/gate/recipe-reconcile.d.ts +103 -0
- package/dist/src/gate/recipe-reconcile.d.ts.map +1 -0
- package/dist/src/gate/recipe-reconcile.js +325 -0
- package/dist/src/gate/recipe-reconcile.js.map +1 -0
- package/dist/src/gate/reply-judge.d.ts +116 -0
- package/dist/src/gate/reply-judge.d.ts.map +1 -0
- package/dist/src/gate/reply-judge.js +467 -0
- package/dist/src/gate/reply-judge.js.map +1 -0
- package/dist/src/gate/types.d.ts +229 -8
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/src/heartbeat/heartbeat.d.ts +184 -0
- package/dist/src/heartbeat/heartbeat.d.ts.map +1 -0
- package/dist/src/heartbeat/heartbeat.js +752 -0
- package/dist/src/heartbeat/heartbeat.js.map +1 -0
- package/dist/src/heartbeat/reminder-time.d.ts +5 -0
- package/dist/src/heartbeat/reminder-time.d.ts.map +1 -0
- package/dist/src/heartbeat/reminder-time.js +107 -0
- package/dist/src/heartbeat/reminder-time.js.map +1 -0
- package/dist/src/index.d.ts +10 -4
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +7 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-mask.d.ts.map +1 -1
- package/dist/src/mcpl/capability-mask.js +3 -2
- package/dist/src/mcpl/capability-mask.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +433 -113
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +1154 -435
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +16 -8
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +29 -15
- 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 +14 -2
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts +7 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.js +32 -7
- package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
- package/dist/src/mcpl/inbound-source.d.ts +26 -10
- package/dist/src/mcpl/inbound-source.d.ts.map +1 -1
- package/dist/src/mcpl/inbound-source.js +40 -69
- package/dist/src/mcpl/inbound-source.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +5 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +4 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/inference-router.d.ts +14 -2
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.js +73 -28
- package/dist/src/mcpl/inference-router.js.map +1 -1
- package/dist/src/mcpl/prose-grammar.d.ts +4 -0
- package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
- package/dist/src/mcpl/prose-grammar.js +6 -0
- package/dist/src/mcpl/prose-grammar.js.map +1 -1
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
- package/dist/src/mcpl/protocol-family.js +3 -0
- package/dist/src/mcpl/protocol-family.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +20 -2
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +33 -11
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +63 -42
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +177 -145
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +380 -46
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +736 -77
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +93 -4
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +176 -11
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tags.d.ts +9 -2
- package/dist/src/mcpl/tags.d.ts.map +1 -1
- package/dist/src/mcpl/tags.js +12 -2
- package/dist/src/mcpl/tags.js.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.d.ts +11 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.js +21 -0
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
- package/dist/src/mcpl/tool-presence.d.ts +311 -0
- package/dist/src/mcpl/tool-presence.d.ts.map +1 -0
- package/dist/src/mcpl/tool-presence.js +697 -0
- package/dist/src/mcpl/tool-presence.js.map +1 -0
- package/dist/src/mcpl/tool-result-normalize.d.ts +9 -3
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.js +14 -6
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
- package/dist/src/mcpl/tool-signature.d.ts +46 -0
- package/dist/src/mcpl/tool-signature.d.ts.map +1 -0
- package/dist/src/mcpl/tool-signature.js +321 -0
- package/dist/src/mcpl/tool-signature.js.map +1 -0
- package/dist/src/mcpl/toolset-host.d.ts +251 -0
- package/dist/src/mcpl/toolset-host.d.ts.map +1 -0
- package/dist/src/mcpl/toolset-host.js +720 -0
- package/dist/src/mcpl/toolset-host.js.map +1 -0
- package/dist/src/mcpl/toolsets-module.d.ts +83 -0
- package/dist/src/mcpl/toolsets-module.d.ts.map +1 -0
- package/dist/src/mcpl/toolsets-module.js +523 -0
- package/dist/src/mcpl/toolsets-module.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +2 -2
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +3 -2
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +24 -2
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/mcpl/visible-content.d.ts +7 -9
- package/dist/src/mcpl/visible-content.d.ts.map +1 -1
- package/dist/src/mcpl/visible-content.js +21 -9
- package/dist/src/mcpl/visible-content.js.map +1 -1
- package/dist/src/module-registry.d.ts +27 -9
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +77 -17
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/discord/index.js +1 -1
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts +2 -1
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +73 -28
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts +108 -6
- package/dist/src/modules/history/semantic.d.ts.map +1 -1
- package/dist/src/modules/history/semantic.js +273 -57
- package/dist/src/modules/history/semantic.js.map +1 -1
- package/dist/src/modules/workspace/answers.d.ts +22 -0
- package/dist/src/modules/workspace/answers.d.ts.map +1 -0
- package/dist/src/modules/workspace/answers.js +123 -0
- package/dist/src/modules/workspace/answers.js.map +1 -0
- package/dist/src/modules/workspace/branch-intent.d.ts +5 -3
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
- package/dist/src/modules/workspace/branch-intent.js +6 -2
- package/dist/src/modules/workspace/branch-intent.js.map +1 -1
- package/dist/src/modules/workspace/direct-write.d.ts +184 -0
- package/dist/src/modules/workspace/direct-write.d.ts.map +1 -0
- package/dist/src/modules/workspace/direct-write.js +417 -0
- package/dist/src/modules/workspace/direct-write.js.map +1 -0
- package/dist/src/modules/workspace/disk-agreement.d.ts +33 -11
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.js +48 -17
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
- package/dist/src/modules/workspace/edit-text.d.ts +27 -12
- package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
- package/dist/src/modules/workspace/edit-text.js +61 -29
- package/dist/src/modules/workspace/edit-text.js.map +1 -1
- package/dist/src/modules/workspace/effects.d.ts +10 -0
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +21 -14
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +308 -78
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +2103 -720
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +75 -63
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +366 -180
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/read-page.d.ts +41 -0
- package/dist/src/modules/workspace/read-page.d.ts.map +1 -0
- package/dist/src/modules/workspace/read-page.js +219 -0
- package/dist/src/modules/workspace/read-page.js.map +1 -0
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
- package/dist/src/modules/workspace/reconcile.js +26 -16
- package/dist/src/modules/workspace/reconcile.js.map +1 -1
- package/dist/src/modules/workspace/result-text.d.ts +68 -0
- package/dist/src/modules/workspace/result-text.d.ts.map +1 -0
- package/dist/src/modules/workspace/result-text.js +94 -0
- package/dist/src/modules/workspace/result-text.js.map +1 -0
- package/dist/src/modules/workspace/sync.d.ts +9 -0
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +14 -0
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/modules/workspace/text.d.ts +29 -12
- package/dist/src/modules/workspace/text.d.ts.map +1 -1
- package/dist/src/modules/workspace/text.js +35 -33
- package/dist/src/modules/workspace/text.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +137 -60
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +8 -3
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/prose-drafts.d.ts +4 -3
- package/dist/src/prose-drafts.d.ts.map +1 -1
- package/dist/src/prose-drafts.js +3 -2
- package/dist/src/prose-drafts.js.map +1 -1
- package/dist/src/reasoning-loss-notice.d.ts +23 -0
- package/dist/src/reasoning-loss-notice.d.ts.map +1 -0
- package/dist/src/reasoning-loss-notice.js +50 -0
- package/dist/src/reasoning-loss-notice.js.map +1 -0
- package/dist/src/reasoning-loss.d.ts +24 -0
- package/dist/src/reasoning-loss.d.ts.map +1 -0
- package/dist/src/reasoning-loss.js +102 -0
- package/dist/src/reasoning-loss.js.map +1 -0
- package/dist/src/send-tools.d.ts +3 -2
- package/dist/src/send-tools.d.ts.map +1 -1
- package/dist/src/send-tools.js +3 -2
- package/dist/src/send-tools.js.map +1 -1
- package/dist/src/sent-rows.d.ts +59 -0
- package/dist/src/sent-rows.d.ts.map +1 -0
- package/dist/src/sent-rows.js +145 -0
- package/dist/src/sent-rows.js.map +1 -0
- package/dist/src/silent-heartbeat.d.ts +17 -10
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/stable-hash.d.ts +3 -0
- package/dist/src/stable-hash.d.ts.map +1 -0
- package/dist/src/stable-hash.js +15 -0
- package/dist/src/stable-hash.js.map +1 -0
- package/dist/src/stored-injections.d.ts +99 -0
- package/dist/src/stored-injections.d.ts.map +1 -0
- package/dist/src/stored-injections.js +106 -0
- package/dist/src/stored-injections.js.map +1 -0
- package/dist/src/text-as-stored.d.ts +4 -3
- package/dist/src/text-as-stored.d.ts.map +1 -1
- package/dist/src/text-as-stored.js +4 -3
- package/dist/src/text-as-stored.js.map +1 -1
- package/dist/src/thinking-drops.d.ts +74 -0
- package/dist/src/thinking-drops.d.ts.map +1 -0
- package/dist/src/thinking-drops.js +96 -0
- package/dist/src/thinking-drops.js.map +1 -0
- package/dist/src/tool-args.d.ts +12 -0
- package/dist/src/tool-args.d.ts.map +1 -1
- package/dist/src/tool-args.js +35 -1
- package/dist/src/tool-args.js.map +1 -1
- package/dist/src/tool-image-ledger.d.ts +9 -8
- package/dist/src/tool-image-ledger.d.ts.map +1 -1
- package/dist/src/tool-image-ledger.js +9 -8
- package/dist/src/tool-image-ledger.js.map +1 -1
- package/dist/src/tool-presentation.d.ts.map +1 -1
- package/dist/src/tool-presentation.js +3 -1
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +12 -6
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +44 -17
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +176 -24
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +411 -91
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/tune-out/coordinator.d.ts +17 -9
- package/dist/src/tune-out/coordinator.d.ts.map +1 -1
- package/dist/src/tune-out/coordinator.js +84 -37
- package/dist/src/tune-out/coordinator.js.map +1 -1
- package/dist/src/tune-out/tools.d.ts +4 -1
- package/dist/src/tune-out/tools.d.ts.map +1 -1
- package/dist/src/types/agent.d.ts +217 -7
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +33 -9
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +39 -3
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +95 -9
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +91 -3
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/agent.ts +661 -67
- package/src/api/server.ts +5 -4
- package/src/auto-continue.ts +43 -0
- package/src/code-execution/isolate/scripts.ts +11 -3
- package/src/cut-off-round.ts +98 -0
- package/src/framework.ts +6168 -1540
- package/src/gate/event-gate.ts +2895 -405
- package/src/gate/index.ts +10 -1
- package/src/gate/recipe-reconcile.ts +355 -0
- package/src/gate/reply-judge.ts +512 -0
- package/src/gate/types.ts +234 -9
- package/src/heartbeat/heartbeat.ts +804 -0
- package/src/heartbeat/reminder-time.ts +107 -0
- package/src/index.ts +11 -4
- package/src/mcpl/capability-mask.ts +3 -2
- package/src/mcpl/channel-registry.ts +1222 -468
- package/src/mcpl/conversation-router.ts +34 -16
- package/src/mcpl/convert-block.ts +14 -1
- package/src/mcpl/hook-orchestrator.ts +33 -6
- package/src/mcpl/inbound-source.ts +42 -69
- package/src/mcpl/index.ts +6 -1
- package/src/mcpl/inference-router.ts +79 -34
- package/src/mcpl/prose-grammar.ts +7 -0
- package/src/mcpl/protocol-family.ts +2 -0
- package/src/mcpl/push-coalescer.ts +51 -13
- package/src/mcpl/reference-fetcher.ts +201 -162
- package/src/mcpl/references.ts +851 -77
- package/src/mcpl/server-connection.ts +189 -11
- package/src/mcpl/tags.ts +13 -2
- package/src/mcpl/tool-lifecycle.ts +21 -0
- package/src/mcpl/tool-presence.ts +716 -0
- package/src/mcpl/tool-result-normalize.ts +18 -6
- package/src/mcpl/tool-signature.ts +316 -0
- package/src/mcpl/toolset-host.ts +727 -0
- package/src/mcpl/toolsets-module.ts +545 -0
- package/src/mcpl/transport.ts +3 -2
- package/src/mcpl/types.ts +25 -2
- package/src/mcpl/visible-content.ts +22 -9
- package/src/module-registry.ts +85 -26
- package/src/modules/discord/index.ts +1 -1
- package/src/modules/history/index.ts +70 -30
- package/src/modules/history/semantic.ts +308 -54
- package/src/modules/workspace/answers.ts +133 -0
- package/src/modules/workspace/branch-intent.ts +10 -5
- package/src/modules/workspace/direct-write.ts +460 -0
- package/src/modules/workspace/disk-agreement.ts +52 -21
- package/src/modules/workspace/edit-text.ts +84 -35
- package/src/modules/workspace/effects.ts +22 -13
- package/src/modules/workspace/index.ts +2142 -723
- package/src/modules/workspace/observe.ts +381 -177
- package/src/modules/workspace/read-page.ts +228 -0
- package/src/modules/workspace/reconcile.ts +27 -15
- package/src/modules/workspace/result-text.ts +121 -0
- package/src/modules/workspace/sync.ts +16 -0
- package/src/modules/workspace/text.ts +58 -32
- package/src/modules/workspace/types.ts +137 -60
- package/src/modules/workspace/watcher.ts +8 -3
- package/src/prose-drafts.ts +4 -3
- package/src/reasoning-loss-notice.ts +62 -0
- package/src/reasoning-loss.ts +120 -0
- package/src/send-tools.ts +3 -2
- package/src/sent-rows.ts +162 -0
- package/src/silent-heartbeat.ts +17 -10
- package/src/stable-hash.ts +14 -0
- package/src/stored-injections.ts +131 -0
- package/src/text-as-stored.ts +4 -3
- package/src/thinking-drops.ts +135 -0
- package/src/tool-args.ts +34 -1
- package/src/tool-image-ledger.ts +9 -8
- package/src/tool-presentation.ts +3 -1
- package/src/tool-result-guard.ts +45 -17
- package/src/tool-result-history.ts +471 -90
- package/src/tune-out/coordinator.ts +80 -37
- package/src/tune-out/tools.ts +4 -1
- package/src/types/agent.ts +220 -7
- package/src/types/events.ts +33 -9
- package/src/types/framework.ts +36 -3
- package/src/types/module.ts +91 -9
- package/src/types/trace.ts +94 -3
|
@@ -46,6 +46,7 @@ import { CapabilityGrant } from './capability-grant.js';
|
|
|
46
46
|
import type { InboundSource } from './inbound-source.js';
|
|
47
47
|
import { McplRequestError, type McplServerConnection } from './server-connection.js';
|
|
48
48
|
import { readSkipReplyWake } from './skip-reply-wake.js';
|
|
49
|
+
import { isProseTargetToken } from './prose-grammar.js';
|
|
49
50
|
|
|
50
51
|
// ============================================================================
|
|
51
52
|
// Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive)
|
|
@@ -134,6 +135,20 @@ class ChannelLifecycleError extends Error {
|
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
/** Whether a lifecycle failure, or one it wraps (an overtaken open's
|
|
139
|
+
* `cause`), is convergence that never established the channel's target.
|
|
140
|
+
* Words don't go into such a channel on the strength of a close the agent
|
|
141
|
+
* decided meanwhile (refusalGivesWay): the address they'd go to is the one
|
|
142
|
+
* that kept changing. A later operation that settles on the current target
|
|
143
|
+
* establishes it, so this is the failure's, not the channel's (compare
|
|
144
|
+
* answeredElsewhere). */
|
|
145
|
+
function convergeFailure(err: unknown): boolean {
|
|
146
|
+
for (let e: unknown = err; e instanceof Error; e = e.cause) {
|
|
147
|
+
if (e instanceof ChannelLifecycleError && e.kind === 'converge') return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
137
152
|
/** Why a channel is selected for opening after a failed open by speech or a
|
|
138
153
|
* reply: its put-back couldn't be recorded, or the server's latest
|
|
139
154
|
* announcement or the subscription policy lifted the default it restored. */
|
|
@@ -367,7 +382,7 @@ function shallowEqualRecord(
|
|
|
367
382
|
// Internal Types
|
|
368
383
|
// ============================================================================
|
|
369
384
|
|
|
370
|
-
/** A registered channel entry, keyed by
|
|
385
|
+
/** A registered channel entry, keyed by its server and id (destinationKey). */
|
|
371
386
|
interface ChannelEntry {
|
|
372
387
|
serverId: string;
|
|
373
388
|
descriptor: ChannelDescriptor;
|
|
@@ -375,6 +390,11 @@ interface ChannelEntry {
|
|
|
375
390
|
open: boolean;
|
|
376
391
|
}
|
|
377
392
|
|
|
393
|
+
/** The ref naming a registered channel's entry. */
|
|
394
|
+
function refOf(entry: ChannelEntry): ChannelRef {
|
|
395
|
+
return { serverId: entry.serverId, channelId: entry.descriptor.id };
|
|
396
|
+
}
|
|
397
|
+
|
|
378
398
|
/**
|
|
379
399
|
* A channel its server declared inbound-only (SPEC §14.2 `direction`): its
|
|
380
400
|
* messages come in, and the host publishes nothing into it. No publish, from
|
|
@@ -531,7 +551,8 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
531
551
|
name: 'channel_close',
|
|
532
552
|
description:
|
|
533
553
|
'Close a channel. Messages that address you still reach you; what else stops reaching you ' +
|
|
534
|
-
'depends on its server.'
|
|
554
|
+
'depends on its server. You can still speak there. It stays closed until you open it with ' +
|
|
555
|
+
'channel_open: neither speaking there nor subscription policy reopens it.',
|
|
535
556
|
inputSchema: {
|
|
536
557
|
type: 'object' as const,
|
|
537
558
|
properties: {
|
|
@@ -637,21 +658,53 @@ export function connectorText(value: unknown): string {
|
|
|
637
658
|
return text.length > limit ? text.slice(0, limit - suffix.length) + suffix : text;
|
|
638
659
|
}
|
|
639
660
|
|
|
640
|
-
/**
|
|
641
|
-
*
|
|
642
|
-
|
|
661
|
+
/**
|
|
662
|
+
* A registered channel, by its server and its id. MCPL requires a channel id
|
|
663
|
+
* to be unique only within one server's connection (§14.2), so two servers
|
|
664
|
+
* can each register `general`, and only the pair names one of them.
|
|
665
|
+
*/
|
|
666
|
+
export interface ChannelRef {
|
|
643
667
|
serverId: string;
|
|
644
668
|
channelId: string;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Where a publish was resolved to go — named by the registry, never by
|
|
672
|
+
* the caller's spelling. */
|
|
673
|
+
export interface PublishDestination extends ChannelRef {
|
|
645
674
|
/** The channel's registered label when the attempt was made. */
|
|
646
675
|
label?: string;
|
|
647
676
|
}
|
|
648
677
|
|
|
649
|
-
/** One key per
|
|
650
|
-
*
|
|
651
|
-
export function destinationKey(to:
|
|
678
|
+
/** One key per channel, its server and id: unlike joining the two with a
|
|
679
|
+
* separator, no two channels share one, whatever their ids hold. */
|
|
680
|
+
export function destinationKey(to: ChannelRef): string {
|
|
652
681
|
return JSON.stringify([to.serverId, to.channelId]);
|
|
653
682
|
}
|
|
654
683
|
|
|
684
|
+
/** Whether two refs name the same channel. Two absent refs are the same:
|
|
685
|
+
* neither names a channel. */
|
|
686
|
+
export function sameChannel(a: ChannelRef | null | undefined, b: ChannelRef | null | undefined): boolean {
|
|
687
|
+
if (!a || !b) return !a && !b;
|
|
688
|
+
return a.serverId === b.serverId && a.channelId === b.channelId;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* A channel's server-qualified name, `serverId:channelId`. A name given as a
|
|
693
|
+
* string (a tool's channelId, a `>>` target, a host's call) may take this
|
|
694
|
+
* form to pick one server's channel where several servers register the id.
|
|
695
|
+
* It is looked up among the registered pairs, never split at a colon, since
|
|
696
|
+
* ids hold colons (ChannelRegistry.entriesNamed).
|
|
697
|
+
*/
|
|
698
|
+
export function qualifiedChannelName(ref: ChannelRef): string {
|
|
699
|
+
return `${ref.serverId}:${ref.channelId}`;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** A channel told apart by its id and its server, for one no string picks
|
|
703
|
+
* alone: `"b:room" on server "a"`. No input takes it for a name. */
|
|
704
|
+
function channelOnServer(ref: ChannelRef): string {
|
|
705
|
+
return `${JSON.stringify(ref.channelId)} on server ${JSON.stringify(ref.serverId)}`;
|
|
706
|
+
}
|
|
707
|
+
|
|
655
708
|
/**
|
|
656
709
|
* What one channels/publish attempt established (ChannelRegistry.publish).
|
|
657
710
|
* - `delivered`: the connector returned `delivered: true`. A historical
|
|
@@ -684,7 +737,9 @@ export interface PublishOutcome {
|
|
|
684
737
|
/**
|
|
685
738
|
* Why the destination refused a publishForAgent before anything went out.
|
|
686
739
|
* Its words say the same, since both come from one check: the channel isn't
|
|
687
|
-
* registered here,
|
|
740
|
+
* registered here, the name given means more than one registered channel
|
|
741
|
+
* (`shared-id`: a ref picks one, as a server-qualified name does where it names
|
|
742
|
+
* one channel alone), it takes no posts, or its server is gone, this host is
|
|
688
743
|
* connecting to it, it's reconnecting, or it's up without channels.publish
|
|
689
744
|
* (serverReach); or the request wasn't sent, as its connection refused it
|
|
690
745
|
* before anything was written (McplRequestError's `not-sent`). A refusal of
|
|
@@ -694,6 +749,7 @@ export interface PublishOutcome {
|
|
|
694
749
|
*/
|
|
695
750
|
export type PublishRefusalReason =
|
|
696
751
|
| 'channel-not-registered'
|
|
752
|
+
| 'shared-id'
|
|
697
753
|
| 'inbound-only'
|
|
698
754
|
| 'server-gone'
|
|
699
755
|
| 'server-connecting'
|
|
@@ -813,30 +869,6 @@ function supersededClause(action: 'open' | 'close', now: DesiredChannelState | u
|
|
|
813
869
|
return action === 'close' ? 'a later decision to keep it open came first' : 'a later decision to close it came first';
|
|
814
870
|
}
|
|
815
871
|
|
|
816
|
-
/** What ends a tune-out, for a channel tool's answer about a tuned-out
|
|
817
|
-
* channel (`channel` as the answer shows it, quoted): its cancel, with the
|
|
818
|
-
* backlog it delivers, and, for a close, that channel_close closes the
|
|
819
|
-
* channel once the cancel has ended the tune-out. */
|
|
820
|
-
function tunedOutPointer(tool: string, channel: string): string {
|
|
821
|
-
return `Call tune_out with channelId ${channel} and mode "cancel" for it to resume normal attention; cancelling ` +
|
|
822
|
-
'delivers the diverted backlog' + (tool === 'channel_close' ? ', and channel_close closes it after that.' : '.');
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
/** A channel tool's refusal of a tuned-out channel, whose cancel is what ends
|
|
826
|
-
* the tune-out, with the backlog it delivers. As an error result the agent
|
|
827
|
-
* reads the pointer itself; `data` names the refusal for an in-process
|
|
828
|
-
* caller. */
|
|
829
|
-
function tunedOutRefusal(tool: string, channelId: string): ToolResult {
|
|
830
|
-
return {
|
|
831
|
-
success: false,
|
|
832
|
-
isError: true,
|
|
833
|
-
error:
|
|
834
|
-
`Channel "${channelId}" is tuned out: its messages go to the subconscious, and ${tool} doesn't change that. ` +
|
|
835
|
-
tunedOutPointer(tool, `"${channelId}"`),
|
|
836
|
-
data: { refusal: 'tuned-out', channelId },
|
|
837
|
-
};
|
|
838
|
-
}
|
|
839
|
-
|
|
840
872
|
/** A clause as the start of a sentence. */
|
|
841
873
|
function sentenceStart(clause: string): string {
|
|
842
874
|
return clause.charAt(0).toUpperCase() + clause.slice(1);
|
|
@@ -953,6 +985,8 @@ interface ChannelRegistryOptions {
|
|
|
953
985
|
onRouteFailure?: (info: {
|
|
954
986
|
conversationId: string;
|
|
955
987
|
channelId: string | null;
|
|
988
|
+
/** The channel's server, beside `channelId` whenever the failure names a channel. */
|
|
989
|
+
serverId?: string;
|
|
956
990
|
reason: string;
|
|
957
991
|
textLen: number;
|
|
958
992
|
/** `failed`: nothing was posted; `unknown`: the request was dispatched
|
|
@@ -991,7 +1025,7 @@ interface ChannelRegistryOptions {
|
|
|
991
1025
|
* whichever channel last spoke (item 3). Returns undefined for the trunk/primary agent, which has
|
|
992
1026
|
* no home and correctly falls back to the global locus (heartbeats, etc.).
|
|
993
1027
|
*/
|
|
994
|
-
homeChannelResolver?: (agentName: string) =>
|
|
1028
|
+
homeChannelResolver?: (agentName: string) => ChannelRef | undefined;
|
|
995
1029
|
/**
|
|
996
1030
|
* Resolve an agent's channel for its CURRENT inference turn, by agent name:
|
|
997
1031
|
* the turn's locus once it has one, else the channel that triggered the
|
|
@@ -1006,9 +1040,11 @@ interface ChannelRegistryOptions {
|
|
|
1006
1040
|
* turn's locus is resolved, so that
|
|
1007
1041
|
* resolution takes the global fallback, which the turn then keeps.
|
|
1008
1042
|
* Consulted AFTER `homeChannelResolver` (a fork's home always wins), BEFORE
|
|
1009
|
-
* `defaultPublishChannel`.
|
|
1043
|
+
* `defaultPublishChannel`. Null where the turn deliberately has no channel,
|
|
1044
|
+
* as one woken for a channel id two servers register, given without its
|
|
1045
|
+
* server: that takes no fallback either.
|
|
1010
1046
|
*/
|
|
1011
|
-
activeChannelResolver?: (agentName: string) =>
|
|
1047
|
+
activeChannelResolver?: (agentName: string) => ChannelRef | null | undefined;
|
|
1012
1048
|
/**
|
|
1013
1049
|
* Whether a conversation takes the process-global fallback, the channel of
|
|
1014
1050
|
* the most recent delivered inbound message: as where its plain speech lands
|
|
@@ -1045,13 +1081,36 @@ interface ChannelRegistryOptions {
|
|
|
1045
1081
|
*/
|
|
1046
1082
|
export type ChannelToolOrigin =
|
|
1047
1083
|
| { kind: 'module'; moduleName?: string }
|
|
1048
|
-
| {
|
|
1084
|
+
| {
|
|
1085
|
+
kind: 'agent';
|
|
1086
|
+
agentName: string;
|
|
1087
|
+
/** For a call made inside one of the agent's turns, that turn's own
|
|
1088
|
+
* channels, every channel that has been its trigger or its locus: a
|
|
1089
|
+
* name one of them answers to by its id means that channel
|
|
1090
|
+
* (ChannelRegistry.ownChannelNamed). */
|
|
1091
|
+
own?: readonly ChannelRef[];
|
|
1092
|
+
};
|
|
1049
1093
|
|
|
1050
1094
|
/** The machine decision sources a module-origin channel_close may record.
|
|
1051
1095
|
* Closed on purpose: honest provenance means a small, auditable vocabulary,
|
|
1052
1096
|
* not arbitrary self-description. */
|
|
1053
1097
|
export const MACHINE_CLOSE_SOURCES = new Set(['subscription-gc', 'housekeeping']);
|
|
1054
1098
|
|
|
1099
|
+
/** A close a module made through its own tool call (ctx.callTool) without a
|
|
1100
|
+
* machine source, such as connectome-host's mentions mode: a decision the
|
|
1101
|
+
* module owns, with its own way back. Speech leaves it closed, as it does a
|
|
1102
|
+
* channel the agent closed (speechOpens), but the agent isn't told how to
|
|
1103
|
+
* open it, since channel_open alone would undo only part of what the
|
|
1104
|
+
* module did. */
|
|
1105
|
+
export const MODULE_CLOSE_SOURCE = 'module-tool';
|
|
1106
|
+
|
|
1107
|
+
/** Whether a closed record is a close the agent or a module decided
|
|
1108
|
+
* (channel_close, channel_decline, or a module's own close), which speech
|
|
1109
|
+
* never undoes. */
|
|
1110
|
+
function isCloseDecision(source: string): boolean {
|
|
1111
|
+
return AGENT_DECISION_SOURCES.has(source) || source === MODULE_CLOSE_SOURCE;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1055
1114
|
export class ChannelRegistry {
|
|
1056
1115
|
private serverRegistry: McplServerRegistry;
|
|
1057
1116
|
private featureSetManager: FeatureSetManager;
|
|
@@ -1071,13 +1130,17 @@ export class ChannelRegistry {
|
|
|
1071
1130
|
source: 'subscription-policy' | 'opened-by-delivery';
|
|
1072
1131
|
channels: Array<{ channelId: string; label?: string }>;
|
|
1073
1132
|
}) => void;
|
|
1074
|
-
private homeChannelResolver?: (agentName: string) =>
|
|
1075
|
-
private activeChannelResolver?: (agentName: string) =>
|
|
1133
|
+
private homeChannelResolver?: (agentName: string) => ChannelRef | undefined;
|
|
1134
|
+
private activeChannelResolver?: (agentName: string) => ChannelRef | null | undefined;
|
|
1076
1135
|
private takesGlobalFallback?: (conversationId: string) => boolean;
|
|
1077
1136
|
private serverConnecting?: (serverId: string) => boolean;
|
|
1078
1137
|
private store?: JsStore;
|
|
1079
1138
|
|
|
1080
|
-
/**
|
|
1139
|
+
/**
|
|
1140
|
+
* Registered channels, keyed by their server and id (destinationKey): a
|
|
1141
|
+
* key that joined the two with a separator would let server `a`'s channel
|
|
1142
|
+
* `b:room` and server `a:b`'s channel `room` share one entry.
|
|
1143
|
+
*/
|
|
1081
1144
|
private channels = new Map<string, ChannelEntry>();
|
|
1082
1145
|
|
|
1083
1146
|
/**
|
|
@@ -1146,17 +1209,19 @@ export class ChannelRegistry {
|
|
|
1146
1209
|
* reaction removal, edit or deletion marker, apart from an edit the
|
|
1147
1210
|
* coalescer delivers on acceptance as the agent's first view: used for
|
|
1148
1211
|
* speech routing and the default publish. */
|
|
1149
|
-
private defaultPublishChannel:
|
|
1212
|
+
private defaultPublishChannel: ChannelRef | null = null;
|
|
1150
1213
|
|
|
1151
1214
|
/** The same message's id and thread, used for buildChannelContext. */
|
|
1152
1215
|
private defaultPublishMessageId: string | null = null;
|
|
1153
1216
|
private defaultPublishThreadId: string | undefined = undefined;
|
|
1154
1217
|
|
|
1155
|
-
/** Per-channel typing indicator timers
|
|
1156
|
-
|
|
1218
|
+
/** Per-channel typing indicator timers, by destinationKey, with the
|
|
1219
|
+
* channel each one types in. */
|
|
1220
|
+
private typingIntervals = new Map<string, { ref: ChannelRef; interval: ReturnType<typeof setInterval> }>();
|
|
1157
1221
|
|
|
1158
|
-
/** Per-channel typing metadata — carried on the 7s
|
|
1159
|
-
* server keeps getting the same routing hints (e.g.
|
|
1222
|
+
/** Per-channel typing metadata, by destinationKey — carried on the 7s
|
|
1223
|
+
* refresh so the target server keeps getting the same routing hints (e.g.
|
|
1224
|
+
* Zulip topic). */
|
|
1160
1225
|
private typingMetadata = new Map<string, Record<string, unknown>>();
|
|
1161
1226
|
|
|
1162
1227
|
/** Chronicle-projected desired lifecycle state, keyed by server + channel.
|
|
@@ -1195,6 +1260,16 @@ export class ChannelRegistry {
|
|
|
1195
1260
|
// Losing an open receipt does not confirm closure. Keep that distinction
|
|
1196
1261
|
// private while preserving the public open:boolean availability projection.
|
|
1197
1262
|
private unconfirmedChannelTargets = new WeakSet<ChannelEntry>();
|
|
1263
|
+
// Registrations whose last open the server answered for another channel,
|
|
1264
|
+
// until an open is answered for this one, or the server registers the
|
|
1265
|
+
// channel again, which makes a new registration: a server that doesn't
|
|
1266
|
+
// resolve this id may not resolve a publish by it either. A close's answer
|
|
1267
|
+
// names no channel, so it doesn't clear the mark, and a retarget keeps the
|
|
1268
|
+
// registration and its mark. Read where speech would put words into such a
|
|
1269
|
+
// channel without an open (publish). Where the grant loses
|
|
1270
|
+
// channels.lifecycle while the connection stays (§6.7), no open can clear
|
|
1271
|
+
// the mark until the server reconnects and registers the channel again.
|
|
1272
|
+
private answeredElsewhere = new WeakSet<ChannelEntry>();
|
|
1198
1273
|
// Marked entries a close failed for since a receipt or traffic last
|
|
1199
1274
|
// confirmed them, cleared where the mark is: a failed request's outcome is
|
|
1200
1275
|
// unknown, so such a close may have closed the channel, where an open,
|
|
@@ -1229,7 +1304,7 @@ export class ChannelRegistry {
|
|
|
1229
1304
|
/** Whether a decided close holds words back from a channel: only where
|
|
1230
1305
|
* the host directs the server's lifecycle. Without that grant the host
|
|
1231
1306
|
* can't close the channel, and words go into a transport that is open,
|
|
1232
|
-
* as the open step
|
|
1307
|
+
* as the open step reads it too (speechOpens). */
|
|
1233
1308
|
private closeStands(entry: ChannelEntry): boolean {
|
|
1234
1309
|
return this.getDesiredState(entry.serverId, entry.descriptor.id) === 'closed' &&
|
|
1235
1310
|
this.directsLifecycle(entry.serverId);
|
|
@@ -1307,7 +1382,8 @@ export class ChannelRegistry {
|
|
|
1307
1382
|
err: unknown,
|
|
1308
1383
|
opts?: { history?: boolean },
|
|
1309
1384
|
): string {
|
|
1310
|
-
|
|
1385
|
+
// As the agent names it back (nameShown), where another server shares its id.
|
|
1386
|
+
const channel = this.quotedShown(refOf(entry));
|
|
1311
1387
|
const tool = action === 'open' ? 'channel_open' : 'channel_close';
|
|
1312
1388
|
const done = action === 'open' ? 'opened' : 'closed';
|
|
1313
1389
|
const later = laterClause(entry);
|
|
@@ -1319,7 +1395,7 @@ export class ChannelRegistry {
|
|
|
1319
1395
|
}
|
|
1320
1396
|
const failure = err instanceof ChannelLifecycleError ? err : undefined;
|
|
1321
1397
|
const now = this.getDesiredState(entry.serverId, entry.descriptor.id);
|
|
1322
|
-
const current = this.
|
|
1398
|
+
const current = this.entryAt(refOf(entry));
|
|
1323
1399
|
if (!current) {
|
|
1324
1400
|
// Its registration may have gone at the server's word or at this
|
|
1325
1401
|
// host's (removeServer). Only a failure that says which (cancelled,
|
|
@@ -1342,13 +1418,13 @@ export class ChannelRegistry {
|
|
|
1342
1418
|
if (action === 'open' ? now !== 'open' : now !== 'closed') {
|
|
1343
1419
|
if (action === 'open' && now === 'tuned-out') {
|
|
1344
1420
|
return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_open doesn't ` +
|
|
1345
|
-
`change that.
|
|
1421
|
+
`change that. ${this.tuneOutEndText(current)}`;
|
|
1346
1422
|
}
|
|
1347
1423
|
// channel_close refuses a tuned-out channel too, so calling it again
|
|
1348
1424
|
// isn't the way: the tune-out's cancel is, and it delivers the backlog.
|
|
1349
1425
|
if (action === 'close' && now === 'tuned-out') {
|
|
1350
1426
|
return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_close doesn't ` +
|
|
1351
|
-
`change that. ${
|
|
1427
|
+
`change that. ${this.tuneOutEndText(current, 'channel_close')}`;
|
|
1352
1428
|
}
|
|
1353
1429
|
// The later decision's own work settles the transport, and its call
|
|
1354
1430
|
// says what became of it, so this says only that this call's change
|
|
@@ -1523,7 +1599,7 @@ export class ChannelRegistry {
|
|
|
1523
1599
|
results.push({ id: String(channel?.id ?? ''), accepted: false, reason: 'invalid descriptor: missing id' });
|
|
1524
1600
|
continue;
|
|
1525
1601
|
}
|
|
1526
|
-
const key =
|
|
1602
|
+
const key = destinationKey({ serverId, channelId: channel.id });
|
|
1527
1603
|
this.registerEntry(key, serverId, channel);
|
|
1528
1604
|
this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
1529
1605
|
registeredIds.push(channel.id);
|
|
@@ -1570,9 +1646,11 @@ export class ChannelRegistry {
|
|
|
1570
1646
|
// we never had is reported, not silently accepted.
|
|
1571
1647
|
if (params.removed) {
|
|
1572
1648
|
for (const channelId of params.removed) {
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1649
|
+
// A server may name the channel by a number: it means the channel
|
|
1650
|
+
// whose id is that number as a string, as it always has.
|
|
1651
|
+
const ref = { serverId, channelId: String(channelId) };
|
|
1652
|
+
const existed = this.channels.delete(destinationKey(ref));
|
|
1653
|
+
this.stopTyping(ref);
|
|
1576
1654
|
addedResults.push({ id: channelId, accepted: existed, reason: existed ? undefined : 'not registered' });
|
|
1577
1655
|
}
|
|
1578
1656
|
}
|
|
@@ -1588,7 +1666,7 @@ export class ChannelRegistry {
|
|
|
1588
1666
|
this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, reason: 'missing id (update)' });
|
|
1589
1667
|
continue;
|
|
1590
1668
|
}
|
|
1591
|
-
const key =
|
|
1669
|
+
const key = destinationKey({ serverId, channelId: channel.id });
|
|
1592
1670
|
const existing = this.channels.get(key);
|
|
1593
1671
|
if (!existing) {
|
|
1594
1672
|
addedResults.push({ id: channel.id, accepted: false, reason: 'not registered — update rejected (§14.5)' });
|
|
@@ -1629,7 +1707,7 @@ export class ChannelRegistry {
|
|
|
1629
1707
|
this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, reason: 'missing id' });
|
|
1630
1708
|
continue;
|
|
1631
1709
|
}
|
|
1632
|
-
const key =
|
|
1710
|
+
const key = destinationKey({ serverId, channelId: channel.id });
|
|
1633
1711
|
this.registerEntry(key, serverId, channel);
|
|
1634
1712
|
this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
1635
1713
|
addedResults.push({ id: channel.id, accepted: true });
|
|
@@ -1714,6 +1792,9 @@ export class ChannelRegistry {
|
|
|
1714
1792
|
// The batch came on the transport open now: a close after this point
|
|
1715
1793
|
// (transportClosed) leaves its traffic nothing to confirm.
|
|
1716
1794
|
const closesAtArrival = this.transportCloses.get(serverId) ?? 0;
|
|
1795
|
+
// And came from a server registered now: one removed since (removeServer)
|
|
1796
|
+
// gives no message the agent replies to (markAccepted).
|
|
1797
|
+
const removalsAtArrival = this.serverRemovals.get(serverId) ?? 0;
|
|
1717
1798
|
|
|
1718
1799
|
for (const message of params.messages) {
|
|
1719
1800
|
if (!message || typeof message.channelId !== 'string' || !message.channelId
|
|
@@ -1750,7 +1831,7 @@ export class ChannelRegistry {
|
|
|
1750
1831
|
// exists registers it first; discord's DM case goes through
|
|
1751
1832
|
// ensureChannelRegistered on push/event, which creates a routable
|
|
1752
1833
|
// entry that isn't open, rather than an open self-attested one.
|
|
1753
|
-
const incomingKey =
|
|
1834
|
+
const incomingKey = destinationKey({ serverId, channelId: message.channelId });
|
|
1754
1835
|
if (!this.channels.has(incomingKey)) {
|
|
1755
1836
|
// Loud, coalesced or not, like every other refusal of a message, but
|
|
1756
1837
|
// at most once a minute per channel (RefusalLines): a server that
|
|
@@ -1828,12 +1909,16 @@ export class ChannelRegistry {
|
|
|
1828
1909
|
const incomingEntry = this.channels.get(incomingKey)!;
|
|
1829
1910
|
const markAccepted = (firstView = false) => {
|
|
1830
1911
|
// Non-conversational markers (a reaction added or removed, an edit, a
|
|
1831
|
-
// deletion
|
|
1912
|
+
// deletion, and a chat:system notice such as a join or the weather)
|
|
1913
|
+
// are ABOUT a message or the place, not one the agent replies to: they
|
|
1832
1914
|
// must not retarget the default publish locus or become
|
|
1833
1915
|
// `context.incoming`'s reply anchor. An edit the coalescer delivered
|
|
1834
1916
|
// as the agent's first view counts as the message it replaced.
|
|
1835
|
-
|
|
1836
|
-
|
|
1917
|
+
// A message the coalescer admits after its server was removed sets no
|
|
1918
|
+
// default: the removal cleared that server's message, and a server
|
|
1919
|
+
// that's gone mustn't displace a live one's default.
|
|
1920
|
+
if (!isMarkerTagged(message.tags, firstView) && (this.serverRemovals.get(serverId) ?? 0) === removalsAtArrival) {
|
|
1921
|
+
this.defaultPublishChannel = { serverId, channelId: message.channelId };
|
|
1837
1922
|
this.defaultPublishMessageId = message.messageId;
|
|
1838
1923
|
this.defaultPublishThreadId = message.threadId;
|
|
1839
1924
|
}
|
|
@@ -2003,7 +2088,9 @@ export class ChannelRegistry {
|
|
|
2003
2088
|
label?: string,
|
|
2004
2089
|
extraMetadata?: Record<string, unknown>,
|
|
2005
2090
|
): void {
|
|
2006
|
-
|
|
2091
|
+
// This server's own registration: another server's channel with the same
|
|
2092
|
+
// id is another channel (ChannelRef), which this one doesn't make.
|
|
2093
|
+
const existing = this.entryAt({ serverId, channelId });
|
|
2007
2094
|
if (existing) {
|
|
2008
2095
|
// Backfill identity onto a bare lazy registration: a DM that first
|
|
2009
2096
|
// arrived with no label/recipient info gets a usable name the next
|
|
@@ -2045,7 +2132,7 @@ export class ChannelRegistry {
|
|
|
2045
2132
|
// the server may still hold a channel an earlier process opened. Counting
|
|
2046
2133
|
// it confirmed closed would answer a close or a decline as already done
|
|
2047
2134
|
// and send nothing, even where the server still delivers.
|
|
2048
|
-
const entry = this.registerEntry(
|
|
2135
|
+
const entry = this.registerEntry(destinationKey({ serverId, channelId }), serverId, {
|
|
2049
2136
|
id: channelId,
|
|
2050
2137
|
type: serverId,
|
|
2051
2138
|
label: label ?? channelId,
|
|
@@ -2066,93 +2153,104 @@ export class ChannelRegistry {
|
|
|
2066
2153
|
// ==========================================================================
|
|
2067
2154
|
|
|
2068
2155
|
/**
|
|
2069
|
-
* Start sending typing indicators for a channel
|
|
2156
|
+
* Start sending typing indicators for a channel: the channel a ref names,
|
|
2157
|
+
* or each channel a name given as a string means (entriesNamed). A host
|
|
2158
|
+
* names channels by the ids it keeps (connectome-host's activity module),
|
|
2159
|
+
* and typing posts nothing, so a name two servers' channels answer to
|
|
2160
|
+
* shows typing in both, rather than in neither or in whichever registered
|
|
2161
|
+
* first. Where two servers reach one platform channel, two bots can then
|
|
2162
|
+
* show typing there.
|
|
2070
2163
|
*
|
|
2071
2164
|
* Sends a typing notification immediately and every 7 seconds thereafter.
|
|
2072
2165
|
* Discord typing indicators last ~10s, so 7s keeps them alive.
|
|
2073
2166
|
*
|
|
2074
|
-
* No-op
|
|
2167
|
+
* No-op for a channel already typing, or not registered.
|
|
2075
2168
|
*/
|
|
2076
|
-
startTyping(
|
|
2169
|
+
startTyping(channel: string | ChannelRef, metadata?: Record<string, unknown>): void {
|
|
2170
|
+
const refs = typeof channel === 'string' ? this.entriesNamed(channel).map(refOf) : [channel];
|
|
2171
|
+
for (const ref of refs) this.startTypingAt(ref, metadata);
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
private startTypingAt(ref: ChannelRef, metadata?: Record<string, unknown>): void {
|
|
2175
|
+
// A channel types only while it's registered, and keeps no metadata
|
|
2176
|
+
// otherwise: each removal of a channel or a server stops its typing
|
|
2177
|
+
// (stopTyping), which drops the metadata too, and stopAll clears both.
|
|
2178
|
+
if (!this.entryAt(ref)) return;
|
|
2179
|
+
const key = destinationKey(ref);
|
|
2077
2180
|
const metadataChanged =
|
|
2078
2181
|
metadata !== undefined &&
|
|
2079
|
-
!shallowEqualRecord(this.typingMetadata.get(
|
|
2182
|
+
!shallowEqualRecord(this.typingMetadata.get(key), metadata);
|
|
2080
2183
|
if (metadata) {
|
|
2081
|
-
this.typingMetadata.set(
|
|
2184
|
+
this.typingMetadata.set(key, metadata);
|
|
2082
2185
|
}
|
|
2083
2186
|
|
|
2084
|
-
if (this.typingIntervals.has(
|
|
2187
|
+
if (this.typingIntervals.has(key)) {
|
|
2085
2188
|
// Already typing. If the caller supplied new routing metadata (e.g. the
|
|
2086
2189
|
// relevant Zulip topic just moved because a newer message arrived),
|
|
2087
2190
|
// dispatch an immediate refresh so the server sees the new routing
|
|
2088
2191
|
// within this request instead of waiting up to TYPING_INTERVAL_MS for
|
|
2089
2192
|
// the next tick.
|
|
2090
|
-
if (metadataChanged)
|
|
2091
|
-
const entry = this.findChannelEntry(channelId);
|
|
2092
|
-
if (entry) {
|
|
2093
|
-
this.sendTypingNotification(entry.serverId, channelId);
|
|
2094
|
-
}
|
|
2095
|
-
}
|
|
2096
|
-
return;
|
|
2097
|
-
}
|
|
2098
|
-
|
|
2099
|
-
// Find the channel entry and its server
|
|
2100
|
-
const entry = this.findChannelEntry(channelId);
|
|
2101
|
-
if (!entry) {
|
|
2193
|
+
if (metadataChanged) this.sendTypingNotification(ref);
|
|
2102
2194
|
return;
|
|
2103
2195
|
}
|
|
2104
2196
|
|
|
2105
2197
|
// Send typing immediately
|
|
2106
|
-
this.sendTypingNotification(
|
|
2198
|
+
this.sendTypingNotification(ref);
|
|
2107
2199
|
|
|
2108
2200
|
// Set up interval — pulls the latest metadata on each tick so mid-stream
|
|
2109
2201
|
// updates (e.g. a newer incoming message switching the relevant topic)
|
|
2110
2202
|
// take effect on the next refresh.
|
|
2111
2203
|
const interval = setInterval(() => {
|
|
2112
|
-
this.sendTypingNotification(
|
|
2204
|
+
this.sendTypingNotification(ref);
|
|
2113
2205
|
}, TYPING_INTERVAL_MS);
|
|
2114
2206
|
|
|
2115
|
-
this.typingIntervals.set(
|
|
2207
|
+
this.typingIntervals.set(key, { ref, interval });
|
|
2116
2208
|
}
|
|
2117
2209
|
|
|
2118
2210
|
/**
|
|
2119
2211
|
* Stop sending typing indicators.
|
|
2120
2212
|
*
|
|
2121
|
-
*
|
|
2122
|
-
*
|
|
2213
|
+
* Given a ref, stops typing in that channel. Given a name as a string,
|
|
2214
|
+
* stops typing in each channel the name means, by its id or its
|
|
2215
|
+
* server-qualified name (entriesNamed), as startTyping started it: a
|
|
2216
|
+
* channel types only while it's registered, since startTypingAt starts
|
|
2217
|
+
* none that isn't and each removal stops its channel's typing. Given
|
|
2218
|
+
* nothing, stops all typing indicators.
|
|
2123
2219
|
*/
|
|
2124
|
-
stopTyping(
|
|
2125
|
-
if (
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2220
|
+
stopTyping(channel?: string | ChannelRef): void {
|
|
2221
|
+
if (channel === undefined) {
|
|
2222
|
+
// Clear all typing intervals and dispatch stop for each known channel
|
|
2223
|
+
const running = [...this.typingIntervals.values()];
|
|
2224
|
+
for (const { interval } of running) clearInterval(interval);
|
|
2225
|
+
this.typingIntervals.clear();
|
|
2226
|
+
for (const { ref } of running) this.sendTypingStop(ref);
|
|
2227
|
+
this.typingMetadata.clear();
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
const refs = typeof channel === 'string' ? this.entriesNamed(channel).map(refOf) : [channel];
|
|
2231
|
+
for (const ref of refs) {
|
|
2232
|
+
const key = destinationKey(ref);
|
|
2233
|
+
const typing = this.typingIntervals.get(key);
|
|
2234
|
+
if (typing) {
|
|
2235
|
+
clearInterval(typing.interval);
|
|
2236
|
+
this.typingIntervals.delete(key);
|
|
2130
2237
|
// Dispatch an explicit 'stop' so servers that support it (e.g. Zulip)
|
|
2131
2238
|
// clear the indicator immediately rather than waiting for auto-expire.
|
|
2132
2239
|
// Metadata still carries the routing hint so the stop hits the same
|
|
2133
|
-
// topic/thread as the start. Guarded by
|
|
2240
|
+
// topic/thread as the start. Guarded by the interval: matches the
|
|
2134
2241
|
// global-clear branch's semantics, and keeps defensive stopTyping(ch)
|
|
2135
2242
|
// calls from spamming stops at a server that never saw a start.
|
|
2136
|
-
|
|
2137
|
-
if (entry && this.sendTypingFn) {
|
|
2138
|
-
this.sendTypingFn(entry.serverId, channelId, this.typingMetadata.get(channelId), 'stop');
|
|
2139
|
-
}
|
|
2140
|
-
}
|
|
2141
|
-
this.typingMetadata.delete(channelId);
|
|
2142
|
-
} else {
|
|
2143
|
-
// Clear all typing intervals and dispatch stop for each known channel
|
|
2144
|
-
const channels = Array.from(this.typingIntervals.keys());
|
|
2145
|
-
for (const interval of this.typingIntervals.values()) {
|
|
2146
|
-
clearInterval(interval);
|
|
2243
|
+
this.sendTypingStop(ref);
|
|
2147
2244
|
}
|
|
2148
|
-
this.
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2245
|
+
this.typingMetadata.delete(key);
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
/** The explicit 'stop' for a channel whose typing ended, while its
|
|
2250
|
+
* server still registers it. */
|
|
2251
|
+
private sendTypingStop(ref: ChannelRef): void {
|
|
2252
|
+
if (this.sendTypingFn && this.entryAt(ref)) {
|
|
2253
|
+
this.sendTypingFn(ref.serverId, ref.channelId, this.typingMetadata.get(destinationKey(ref)), 'stop');
|
|
2156
2254
|
}
|
|
2157
2255
|
}
|
|
2158
2256
|
|
|
@@ -2166,14 +2264,10 @@ export class ChannelRegistry {
|
|
|
2166
2264
|
* deletion marker, apart from an edit the coalescer delivers on acceptance
|
|
2167
2265
|
* as the agent's first view.
|
|
2168
2266
|
*/
|
|
2169
|
-
getDefaultPublishChannel():
|
|
2267
|
+
getDefaultPublishChannel(): ChannelRef | null {
|
|
2170
2268
|
return this.defaultPublishChannel;
|
|
2171
2269
|
}
|
|
2172
2270
|
|
|
2173
|
-
/**
|
|
2174
|
-
* Get the descriptor for a channel by its channelId (first match across
|
|
2175
|
-
* servers). Used by the conversation router for DM classification.
|
|
2176
|
-
*/
|
|
2177
2271
|
/**
|
|
2178
2272
|
* RFC-006 §3.2: is `channelId` registered BY `serverId` through a server
|
|
2179
2273
|
* declaration (channels/register, channels/changed) or an authorized open?
|
|
@@ -2182,30 +2276,25 @@ export class ChannelRegistry {
|
|
|
2182
2276
|
* registration.
|
|
2183
2277
|
*/
|
|
2184
2278
|
isDeclaredChannel(serverId: string, channelId: string): boolean {
|
|
2185
|
-
const entry = this.
|
|
2279
|
+
const entry = this.entryAt({ serverId, channelId });
|
|
2186
2280
|
return !!entry && !pushPlaceholder(entry);
|
|
2187
2281
|
}
|
|
2188
2282
|
|
|
2189
|
-
|
|
2190
|
-
|
|
2283
|
+
/** A registered channel's descriptor. Used by the conversation router for
|
|
2284
|
+
* DM classification. */
|
|
2285
|
+
getDescriptor(ref: ChannelRef): ChannelDescriptor | undefined {
|
|
2286
|
+
return this.entryAt(ref)?.descriptor;
|
|
2191
2287
|
}
|
|
2192
2288
|
|
|
2193
2289
|
/** The registered label of `channelId` on exactly `serverId` (no
|
|
2194
2290
|
* cross-server first match), or undefined when it has none. */
|
|
2195
2291
|
getChannelLabel(serverId: string, channelId: string): string | undefined {
|
|
2196
|
-
const label = this.
|
|
2292
|
+
const label = this.entryAt({ serverId, channelId })?.descriptor.label;
|
|
2197
2293
|
return typeof label === 'string' && label.length > 0 ? label : undefined;
|
|
2198
2294
|
}
|
|
2199
2295
|
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
* register that id (findChannelEntry). The map's key joins the two ids
|
|
2203
|
-
* with a colon, so another server's entry can sit under a key: it has to
|
|
2204
|
-
* be this server's (as in streamingServerAt). */
|
|
2205
|
-
isChannelOpen(channelId: string, serverId?: string): boolean {
|
|
2206
|
-
if (serverId === undefined) return this.findChannelEntry(channelId)?.open === true;
|
|
2207
|
-
const entry = this.channels.get(`${serverId}:${channelId}`);
|
|
2208
|
-
return entry?.serverId === serverId && entry.open === true;
|
|
2296
|
+
isChannelOpen(ref: ChannelRef): boolean {
|
|
2297
|
+
return this.entryAt(ref)?.open === true;
|
|
2209
2298
|
}
|
|
2210
2299
|
|
|
2211
2300
|
getDesiredState(serverId: string, channelId: string): DesiredChannelState | undefined {
|
|
@@ -2252,6 +2341,9 @@ export class ChannelRegistry {
|
|
|
2252
2341
|
toolName: string,
|
|
2253
2342
|
input: unknown,
|
|
2254
2343
|
origin?: ChannelToolOrigin,
|
|
2344
|
+
/** Filled with a channel_publish's outcome once its request is
|
|
2345
|
+
* attempted, so the caller learns where it was delivered. */
|
|
2346
|
+
seen?: { outcome?: PublishOutcome },
|
|
2255
2347
|
): Promise<ToolResult> {
|
|
2256
2348
|
switch (toolName) {
|
|
2257
2349
|
case 'channel_list':
|
|
@@ -2263,7 +2355,7 @@ export class ChannelRegistry {
|
|
|
2263
2355
|
serverId?: string;
|
|
2264
2356
|
backscroll?: number;
|
|
2265
2357
|
beforeMessageId?: string;
|
|
2266
|
-
});
|
|
2358
|
+
}, origin?.kind === 'agent' ? origin.own : undefined);
|
|
2267
2359
|
|
|
2268
2360
|
case 'channel_close':
|
|
2269
2361
|
return this.handleToolClose(
|
|
@@ -2282,10 +2374,10 @@ export class ChannelRegistry {
|
|
|
2282
2374
|
serverId?: string;
|
|
2283
2375
|
messageId: string;
|
|
2284
2376
|
acknowledge?: string;
|
|
2285
|
-
});
|
|
2377
|
+
}, origin?.kind === 'agent' ? origin.own : undefined);
|
|
2286
2378
|
|
|
2287
2379
|
case 'channel_publish':
|
|
2288
|
-
return this.handleToolPublish(input as { channelId
|
|
2380
|
+
return this.handleToolPublish(input as { channelId?: string; serverId?: string; content?: string; text?: string }, origin, seen);
|
|
2289
2381
|
|
|
2290
2382
|
case 'think':
|
|
2291
2383
|
return this.handleToolThink(input as { content?: string });
|
|
@@ -2312,13 +2404,39 @@ export class ChannelRegistry {
|
|
|
2312
2404
|
// ==========================================================================
|
|
2313
2405
|
|
|
2314
2406
|
/**
|
|
2315
|
-
*
|
|
2316
|
-
*
|
|
2317
|
-
*
|
|
2407
|
+
* The message the agent is replying to, with its channel: the most recent
|
|
2408
|
+
* inbound message that isn't a reaction, reaction removal, edit or deletion
|
|
2409
|
+
* marker, apart from an edit the coalescer delivers on acceptance as the
|
|
2410
|
+
* agent's first view. Left process-global: per-channel inbound tracking
|
|
2411
|
+
* (the right messageId for a fork's own channel) is a separate concern from
|
|
2412
|
+
* the outbound routing fix. An ephemeral run has none (takesGlobalFallback),
|
|
2413
|
+
* so a subagent's refusal marks no one else's channel. Nor has a turn that
|
|
2414
|
+
* deliberately has no channel (activeChannelResolver's null): woken for a
|
|
2415
|
+
* channel id two servers register, it isn't replying to the latest inbound
|
|
2416
|
+
* message, which may be anywhere. A fork's turn always has a channel, its
|
|
2417
|
+
* home, so it's taken to be replying to the latest inbound message as every
|
|
2418
|
+
* fork's turn is, in explicit mode as in locus mode, where its locus is
|
|
2419
|
+
* its home.
|
|
2318
2420
|
*/
|
|
2319
|
-
|
|
2320
|
-
|
|
2421
|
+
incomingFor(agentName?: string): { channel: ChannelRef; messageId: string; threadId?: string } | undefined {
|
|
2422
|
+
if (agentName !== undefined && this.activeChannelResolver?.(agentName) === null && !this.homeChannelResolver?.(agentName)) {
|
|
2423
|
+
return undefined;
|
|
2424
|
+
}
|
|
2425
|
+
const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
|
|
2426
|
+
if (!fallback || !this.defaultPublishMessageId) return undefined;
|
|
2427
|
+
return { channel: fallback, messageId: this.defaultPublishMessageId, threadId: this.defaultPublishThreadId };
|
|
2428
|
+
}
|
|
2321
2429
|
|
|
2430
|
+
/**
|
|
2431
|
+
* The channel context one server is told for an agent's inference
|
|
2432
|
+
* (context/beforeInference, §14.4). Each server is told only of its own
|
|
2433
|
+
* channels: ids are unique only within one server's connection (§14.2), so
|
|
2434
|
+
* another server's channel named by its id alone could read as this
|
|
2435
|
+
* server's own, and §14.4 leaves the field to the host. So `incoming` and
|
|
2436
|
+
* `defaultOutgoing` appear only where that channel is `serverId`'s, and
|
|
2437
|
+
* `candidates` lists only its open channels. Undefined when none applies.
|
|
2438
|
+
*/
|
|
2439
|
+
buildChannelContext(agentName: string | undefined, serverId: string): ChannelContext | undefined {
|
|
2322
2440
|
// Resolve the outbound locus the SAME way routeSpeech does, so the agent is
|
|
2323
2441
|
// told the channel its speech will actually land in (none, when its server
|
|
2324
2442
|
// declared it inbound-only, since speech isn't posted there): a conversation
|
|
@@ -2335,43 +2453,37 @@ export class ChannelRegistry {
|
|
|
2335
2453
|
// the latest inbound message's channel as its outgoing one, nor that
|
|
2336
2454
|
// message as the one it is replying to.
|
|
2337
2455
|
const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
if (openChannels.length === 0 && !outgoing) {
|
|
2341
|
-
return undefined;
|
|
2342
|
-
}
|
|
2456
|
+
// A turn that deliberately has no channel (null) takes no fallback.
|
|
2457
|
+
const outgoing = home ?? (active !== undefined ? active : fallback);
|
|
2343
2458
|
|
|
2344
2459
|
const context: ChannelContext = {};
|
|
2345
2460
|
|
|
2346
|
-
// Incoming: the
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
// agent is replying to).
|
|
2350
|
-
// Left process-global — per-channel inbound tracking (the right messageId for
|
|
2351
|
-
// a fork's own channel) is a separate concern from the outbound routing fix.
|
|
2352
|
-
if (fallback && this.defaultPublishMessageId) {
|
|
2461
|
+
// Incoming: the message the agent is replying to (incomingFor).
|
|
2462
|
+
const incoming = this.incomingFor(agentName);
|
|
2463
|
+
if (incoming && incoming.channel.serverId === serverId) {
|
|
2353
2464
|
context.incoming = {
|
|
2354
|
-
channelId:
|
|
2355
|
-
messageId:
|
|
2356
|
-
threadId:
|
|
2465
|
+
channelId: incoming.channel.channelId,
|
|
2466
|
+
messageId: incoming.messageId,
|
|
2467
|
+
threadId: incoming.threadId,
|
|
2357
2468
|
};
|
|
2358
2469
|
}
|
|
2359
2470
|
|
|
2360
2471
|
// Default outgoing: the resolved outbound locus (home channel for forks),
|
|
2361
2472
|
// unless its server declared it inbound-only: routeSpeech publishes nothing
|
|
2362
2473
|
// there, so it isn't where the agent's speech goes.
|
|
2363
|
-
if (outgoing && !this.inboundOnlyServer(outgoing)) {
|
|
2474
|
+
if (outgoing && outgoing.serverId === serverId && !this.inboundOnlyServer(outgoing)) {
|
|
2364
2475
|
context.defaultOutgoing = {
|
|
2365
|
-
channelId: outgoing,
|
|
2476
|
+
channelId: outgoing.channelId,
|
|
2366
2477
|
};
|
|
2367
2478
|
}
|
|
2368
2479
|
|
|
2369
|
-
// Candidates:
|
|
2370
|
-
|
|
2371
|
-
|
|
2480
|
+
// Candidates: this server's open channels.
|
|
2481
|
+
const candidates = this.getOpenChannels().filter((e) => e.serverId === serverId).map((e) => e.descriptor.id);
|
|
2482
|
+
if (candidates.length > 0) {
|
|
2483
|
+
context.candidates = candidates;
|
|
2372
2484
|
}
|
|
2373
2485
|
|
|
2374
|
-
return context;
|
|
2486
|
+
return Object.keys(context).length > 0 ? context : undefined;
|
|
2375
2487
|
}
|
|
2376
2488
|
|
|
2377
2489
|
// ==========================================================================
|
|
@@ -2391,12 +2503,18 @@ export class ChannelRegistry {
|
|
|
2391
2503
|
for (const [key, entry] of this.channels) {
|
|
2392
2504
|
if (entry.serverId !== serverId) continue;
|
|
2393
2505
|
this.channels.delete(key);
|
|
2394
|
-
this.stopTyping(entry
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2506
|
+
this.stopTyping(refOf(entry));
|
|
2507
|
+
}
|
|
2508
|
+
// The default channel's ref stays, as a channels/changed removal leaves
|
|
2509
|
+
// it: a call that would default to it is refused as the default gone,
|
|
2510
|
+
// naming it, rather than told there's no default yet, and another
|
|
2511
|
+
// server's channel with its id isn't taken for it. Its message and
|
|
2512
|
+
// thread go, as on main, so no message on a server that's gone is given
|
|
2513
|
+
// as the one an agent replies to (incomingFor), to that server added
|
|
2514
|
+
// back or to a refusal's reaction.
|
|
2515
|
+
if (this.defaultPublishChannel?.serverId === serverId) {
|
|
2516
|
+
this.defaultPublishMessageId = null;
|
|
2517
|
+
this.defaultPublishThreadId = undefined;
|
|
2400
2518
|
}
|
|
2401
2519
|
// Desired state and migration markers deliberately survive disconnects.
|
|
2402
2520
|
}
|
|
@@ -2441,11 +2559,13 @@ export class ChannelRegistry {
|
|
|
2441
2559
|
* Stop all typing intervals and clear all channel registrations.
|
|
2442
2560
|
*/
|
|
2443
2561
|
stopAll(): void {
|
|
2444
|
-
// Clear all typing intervals
|
|
2445
|
-
|
|
2562
|
+
// Clear all typing intervals, and their metadata, which a channel
|
|
2563
|
+
// registered again later would otherwise send
|
|
2564
|
+
for (const { interval } of this.typingIntervals.values()) {
|
|
2446
2565
|
clearInterval(interval);
|
|
2447
2566
|
}
|
|
2448
2567
|
this.typingIntervals.clear();
|
|
2568
|
+
this.typingMetadata.clear();
|
|
2449
2569
|
|
|
2450
2570
|
// Clear channels map
|
|
2451
2571
|
this.channels.clear();
|
|
@@ -2855,17 +2975,30 @@ export class ChannelRegistry {
|
|
|
2855
2975
|
): void {
|
|
2856
2976
|
const key = this.lifecycleKey(serverId, channelId);
|
|
2857
2977
|
const current = this.desiredStates.get(key);
|
|
2978
|
+
// A machine's close isn't a decision: of a channel with no record (one
|
|
2979
|
+
// registered lazily, or on a server whose grant lacks channels.lifecycle),
|
|
2980
|
+
// it records the default that channel already had, so policy and
|
|
2981
|
+
// bootstrap can still lift it (#417).
|
|
2982
|
+
const recorded = !current && desired === 'closed' && MACHINE_CLOSE_SOURCES.has(source) ? 'default-closed' : source;
|
|
2858
2983
|
// A decision that records the state already on record changes nothing,
|
|
2859
|
-
// so it writes nothing,
|
|
2860
|
-
// channel_close or channel_decline)
|
|
2861
|
-
// then says the agent decided,
|
|
2862
|
-
//
|
|
2863
|
-
//
|
|
2864
|
-
//
|
|
2865
|
-
//
|
|
2866
|
-
// the only decision of the same
|
|
2867
|
-
// record here.
|
|
2868
|
-
|
|
2984
|
+
// so it writes nothing, with two exceptions. The agent's own decision
|
|
2985
|
+
// (channel_open, channel_close or channel_decline) records itself over a
|
|
2986
|
+
// record that isn't the agent's. The record then says the agent decided,
|
|
2987
|
+
// which is all handleToolClose's explicit-open guard and
|
|
2988
|
+
// ensureInitialDesiredState's lift of a default ask of it. openChannelNow
|
|
2989
|
+
// depends on it too: a failed open by speech or a reply tells a decision
|
|
2990
|
+
// made during its round-trip by the record no longer being the object it
|
|
2991
|
+
// wrote, and the agent's channel_open, the only decision of the same
|
|
2992
|
+
// state that can be made then, gets a new record here. And the latest
|
|
2993
|
+
// deliberate close wins: the agent's close or decline, or a module's
|
|
2994
|
+
// close, records itself over any closed record from another source,
|
|
2995
|
+
// another deliberate close included, so a decline after a close is the
|
|
2996
|
+
// decision on record. Speech opens no channel whose close was decided
|
|
2997
|
+
// (speechOpens). A machine's close never replaces a closed record, so
|
|
2998
|
+
// what nobody decided stays liftable.
|
|
2999
|
+
const agentOverOther = AGENT_DECISION_SOURCES.has(source) && !AGENT_DECISION_SOURCES.has(current?.source ?? '');
|
|
3000
|
+
const latestClose = desired === 'closed' && isCloseDecision(source) && current?.source !== source;
|
|
3001
|
+
if (current?.state === desired && !agentOverOther && !latestClose) {
|
|
2869
3002
|
return;
|
|
2870
3003
|
}
|
|
2871
3004
|
this.appendLifecycleEvent({
|
|
@@ -2873,10 +3006,10 @@ export class ChannelRegistry {
|
|
|
2873
3006
|
serverId,
|
|
2874
3007
|
channelId,
|
|
2875
3008
|
desired,
|
|
2876
|
-
source,
|
|
3009
|
+
source: recorded,
|
|
2877
3010
|
timestamp: new Date().toISOString(),
|
|
2878
3011
|
});
|
|
2879
|
-
this.desiredStates.set(key, { state: desired, source });
|
|
3012
|
+
this.desiredStates.set(key, { state: desired, source: recorded });
|
|
2880
3013
|
}
|
|
2881
3014
|
|
|
2882
3015
|
// ==========================================================================
|
|
@@ -2967,7 +3100,7 @@ export class ChannelRegistry {
|
|
|
2967
3100
|
return this.getTuneOutState(serverId, channelId) !== null &&
|
|
2968
3101
|
!this.channelLifecycleTails.has(this.lifecycleKey(serverId, channelId)) &&
|
|
2969
3102
|
this.serverReach(serverId) === 'up' &&
|
|
2970
|
-
this.tuneOutReadsOpen(this.channels.get(
|
|
3103
|
+
this.tuneOutReadsOpen(this.channels.get(destinationKey({ serverId, channelId })));
|
|
2971
3104
|
}
|
|
2972
3105
|
|
|
2973
3106
|
/**
|
|
@@ -2995,7 +3128,7 @@ export class ChannelRegistry {
|
|
|
2995
3128
|
channelId: string,
|
|
2996
3129
|
epochId: string | undefined,
|
|
2997
3130
|
): Promise<string | undefined> {
|
|
2998
|
-
const channelKey =
|
|
3131
|
+
const channelKey = destinationKey({ serverId, channelId });
|
|
2999
3132
|
const lifecycleKey = this.lifecycleKey(serverId, channelId);
|
|
3000
3133
|
const inEffect = () =>
|
|
3001
3134
|
epochId !== undefined && this.getTuneOutState(serverId, channelId)?.params.epochId === epochId;
|
|
@@ -3207,32 +3340,48 @@ export class ChannelRegistry {
|
|
|
3207
3340
|
* the resident matches none. A refusal of the destination before anything
|
|
3208
3341
|
* went out carries its `reason` (PublishRefusalReason), so a caller can act
|
|
3209
3342
|
* on why nothing was posted without reading the words.
|
|
3343
|
+
*
|
|
3344
|
+
* The channel is a ref, or a name given as a string, which resolves as
|
|
3345
|
+
* channel_publish's channelId does: one channel the name means, which may
|
|
3346
|
+
* be server-qualified, and a name two servers' channels answer to is
|
|
3347
|
+
* refused with them as candidates (`shared-id`). A host keeps channels by
|
|
3348
|
+
* their ids (connectome-host's notices module). Where several servers
|
|
3349
|
+
* register an id, a ref picks one server's channel, as a qualified name does
|
|
3350
|
+
* where it names one channel alone.
|
|
3210
3351
|
*/
|
|
3211
3352
|
async publishForAgent(
|
|
3212
|
-
|
|
3353
|
+
channel: string | ChannelRef,
|
|
3213
3354
|
text: string,
|
|
3214
3355
|
agentName: string,
|
|
3215
3356
|
): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean; reason?: PublishRefusalReason }> {
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
const seen: { outcome?: PublishOutcome; refusal?: PublishRefusalReason } = {};
|
|
3220
|
-
const result = await this.handleToolPublish(
|
|
3221
|
-
|
|
3357
|
+
const input = typeof channel === 'string'
|
|
3358
|
+
? { channelId: channel, content: text }
|
|
3359
|
+
: { channelId: channel.channelId, serverId: channel.serverId, content: text };
|
|
3360
|
+
const seen: { outcome?: PublishOutcome; resolved?: ChannelRef; refusal?: PublishRefusalReason } = {};
|
|
3361
|
+
const result = await this.handleToolPublish(input, undefined, seen, 'speak_in_channel');
|
|
3362
|
+
// The destination the publish resolved before it awaited, which the
|
|
3363
|
+
// outcome carries: the channel may be removed while the reply is pending.
|
|
3364
|
+
const destination = seen.outcome?.destination;
|
|
3365
|
+
if (seen.outcome?.status === 'delivered' && destination) {
|
|
3222
3366
|
this.emitTraceFn({
|
|
3223
3367
|
type: 'mcpl:speech-routed',
|
|
3224
3368
|
conversationId: agentName,
|
|
3225
|
-
serverId,
|
|
3226
|
-
channelId,
|
|
3369
|
+
serverId: destination.serverId,
|
|
3370
|
+
channelId: destination.channelId,
|
|
3227
3371
|
delivered: true,
|
|
3228
3372
|
textLen: text.length,
|
|
3229
3373
|
text,
|
|
3230
3374
|
});
|
|
3231
3375
|
} else {
|
|
3376
|
+
// The channel the publish went to, or that the call named before a
|
|
3377
|
+
// refusal, or, where nothing resolved, the server the caller gave, with
|
|
3378
|
+
// the name it gave.
|
|
3379
|
+
const named = destination ?? seen.resolved;
|
|
3232
3380
|
this.emitTraceFn({
|
|
3233
3381
|
type: 'mcpl:speech-route-failed',
|
|
3234
3382
|
conversationId: agentName,
|
|
3235
|
-
|
|
3383
|
+
...(named ? { serverId: named.serverId } : 'serverId' in input ? { serverId: input.serverId } : {}),
|
|
3384
|
+
channelId: named?.channelId ?? input.channelId,
|
|
3236
3385
|
// The publish's own reason and the connector's detail, as
|
|
3237
3386
|
// deliverSpeech's trace gives them, or the refusal before any publish.
|
|
3238
3387
|
reason: seen.outcome?.reason ?? result.error ?? 'the publish failed',
|
|
@@ -3366,7 +3515,7 @@ export class ChannelRegistry {
|
|
|
3366
3515
|
removals: number,
|
|
3367
3516
|
options?: { history?: ChannelHistoryRequest },
|
|
3368
3517
|
): Promise<{ open: boolean; result?: ChannelsOpenResult }> {
|
|
3369
|
-
const channelKey =
|
|
3518
|
+
const channelKey = destinationKey({ serverId, channelId });
|
|
3370
3519
|
const lifecycleKey = this.lifecycleKey(serverId, channelId);
|
|
3371
3520
|
try {
|
|
3372
3521
|
let attempts = 0;
|
|
@@ -3479,6 +3628,7 @@ export class ChannelRegistry {
|
|
|
3479
3628
|
// its sends either.
|
|
3480
3629
|
if (substitute !== undefined) {
|
|
3481
3630
|
this.invalidateTransportConfirmation(entry);
|
|
3631
|
+
this.answeredElsewhere.add(entry);
|
|
3482
3632
|
throw new ChannelLifecycleError(
|
|
3483
3633
|
`the "${serverId}" server answered for ` +
|
|
3484
3634
|
(substitute.kind === 'other' ? 'a different channel' : 'a channel whose id isn\'t a string') +
|
|
@@ -3488,6 +3638,7 @@ export class ChannelRegistry {
|
|
|
3488
3638
|
}
|
|
3489
3639
|
if (open) this.applyOpenDeclaration(entry, result);
|
|
3490
3640
|
entry.open = open;
|
|
3641
|
+
if (open) this.answeredElsewhere.delete(entry);
|
|
3491
3642
|
this.unconfirmedChannelTargets.delete(entry);
|
|
3492
3643
|
this.failedCloseTargets.delete(entry);
|
|
3493
3644
|
const now = this.getDesiredState(serverId, channelId);
|
|
@@ -3539,7 +3690,7 @@ export class ChannelRegistry {
|
|
|
3539
3690
|
* doesn't know needs nothing: §14.5 refuses its traffic.
|
|
3540
3691
|
*/
|
|
3541
3692
|
private closeSubstitutedChannel(serverId: string, channelId: string): void {
|
|
3542
|
-
if (!this.channels.has(
|
|
3693
|
+
if (!this.channels.has(destinationKey({ serverId, channelId }))) return;
|
|
3543
3694
|
if (this.getDesiredState(serverId, channelId) !== 'closed') return;
|
|
3544
3695
|
this.applyThroughQueue(serverId, channelId);
|
|
3545
3696
|
}
|
|
@@ -3557,7 +3708,7 @@ export class ChannelRegistry {
|
|
|
3557
3708
|
this.applyDesiredChannelState(serverId, channelId).catch((err) => {
|
|
3558
3709
|
// Removal while queued is cancellation, as in reconcileChannels, and
|
|
3559
3710
|
// exhausted convergence emitted its own diagnostic.
|
|
3560
|
-
if (!this.channels.has(
|
|
3711
|
+
if (!this.channels.has(destinationKey({ serverId, channelId })) ||
|
|
3561
3712
|
(err instanceof ChannelLifecycleError && (err.kind === 'cancelled' || err.kind === 'converge'))) return;
|
|
3562
3713
|
this.emitTraceFn({
|
|
3563
3714
|
type: 'mcpl:channel-reconcile-failed',
|
|
@@ -3596,7 +3747,7 @@ export class ChannelRegistry {
|
|
|
3596
3747
|
// loop, so stop waiting rather than spin.
|
|
3597
3748
|
if (this.channelLifecycleTails.get(lifecycleKey) === pending) break;
|
|
3598
3749
|
}
|
|
3599
|
-
const current = this.
|
|
3750
|
+
const current = this.entryAt({ serverId: entry.serverId, channelId: entry.descriptor.id });
|
|
3600
3751
|
if (!current) throw new ChannelLifecycleError(CHANNEL_REMOVED_CLAUSE, 'removed');
|
|
3601
3752
|
// A close that stands is what the words met, whatever became of an open,
|
|
3602
3753
|
// and whether or not the close itself took: one that failed may have
|
|
@@ -3641,7 +3792,7 @@ export class ChannelRegistry {
|
|
|
3641
3792
|
// Record all admissions before waiting on any channel. A slow first RPC
|
|
3642
3793
|
// must not delay intent for the rest of this registration batch.
|
|
3643
3794
|
for (const channel of channels) {
|
|
3644
|
-
const entry = this.
|
|
3795
|
+
const entry = this.entryAt({ serverId, channelId: channel.id });
|
|
3645
3796
|
if (entry && this.ensureInitialDesiredState(serverId, entry.descriptor)) {
|
|
3646
3797
|
policyOpened.push({ channelId: channel.id, label: entry.descriptor.label });
|
|
3647
3798
|
}
|
|
@@ -3665,14 +3816,14 @@ export class ChannelRegistry {
|
|
|
3665
3816
|
// Each channel has its own lifecycle queue, so a held RPC for one (up to
|
|
3666
3817
|
// its timeout, times its attempts) doesn't delay its siblings.
|
|
3667
3818
|
await Promise.allSettled(channels.map(async (channel) => {
|
|
3668
|
-
if (!this.channels.has(
|
|
3819
|
+
if (!this.channels.has(destinationKey({ serverId, channelId: channel.id }))) return;
|
|
3669
3820
|
try {
|
|
3670
3821
|
await this.applyDesiredChannelState(serverId, channel.id);
|
|
3671
3822
|
} catch (err) {
|
|
3672
3823
|
// Removal while queued is cancellation, not a failed subscription.
|
|
3673
3824
|
// Exhausted convergence and a substituted answer already emitted
|
|
3674
3825
|
// their own diagnostics.
|
|
3675
|
-
if (!this.channels.has(
|
|
3826
|
+
if (!this.channels.has(destinationKey({ serverId, channelId: channel.id })) ||
|
|
3676
3827
|
(err instanceof ChannelLifecycleError &&
|
|
3677
3828
|
(err.kind === 'cancelled' || err.kind === 'converge' || err.kind === 'substituted'))) return;
|
|
3678
3829
|
this.emitTraceFn({
|
|
@@ -3696,13 +3847,13 @@ export class ChannelRegistry {
|
|
|
3696
3847
|
* Uses the sendTypingFn callback if provided. If not, this is a no-op
|
|
3697
3848
|
* (typing timer lifecycle is still managed for when the callback is wired).
|
|
3698
3849
|
*/
|
|
3699
|
-
private sendTypingNotification(
|
|
3850
|
+
private sendTypingNotification(ref: ChannelRef): void {
|
|
3700
3851
|
// §14.1: channels/typing requires channels.typing in the grant. Silent
|
|
3701
3852
|
// skip — a typing indicator is cosmetic and per-7s, so a diagnostic per
|
|
3702
3853
|
// tick would be noise.
|
|
3703
|
-
if (!CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.typing')) return;
|
|
3854
|
+
if (!CapabilityGrant.of(this.serverRegistry.getServer(ref.serverId)).has('channels.typing')) return;
|
|
3704
3855
|
if (this.sendTypingFn) {
|
|
3705
|
-
this.sendTypingFn(serverId, channelId, this.typingMetadata.get(
|
|
3856
|
+
this.sendTypingFn(ref.serverId, ref.channelId, this.typingMetadata.get(destinationKey(ref)));
|
|
3706
3857
|
}
|
|
3707
3858
|
// TODO: When server-connection exposes a public sendNotification or
|
|
3708
3859
|
// sendTyping method, wire it here directly instead of using a callback.
|
|
@@ -3719,76 +3870,274 @@ export class ChannelRegistry {
|
|
|
3719
3870
|
.map((e) => e.descriptor);
|
|
3720
3871
|
}
|
|
3721
3872
|
|
|
3722
|
-
/** Server id owning a registered channel (by descriptor id), or null. */
|
|
3723
|
-
getChannelServerId(channelId: string): string | null {
|
|
3724
|
-
return this.findChannelEntry(channelId)?.serverId ?? null;
|
|
3725
|
-
}
|
|
3726
|
-
|
|
3727
3873
|
/**
|
|
3728
3874
|
* The server that declared this channel inbound-only (isInboundOnly), or
|
|
3729
|
-
* undefined when it isn't one. The notices name that
|
|
3730
|
-
*
|
|
3731
|
-
* its id alone (findChannelEntry).
|
|
3875
|
+
* undefined when it isn't one, or isn't registered. The notices name that
|
|
3876
|
+
* server.
|
|
3732
3877
|
*/
|
|
3733
|
-
inboundOnlyServer(
|
|
3734
|
-
const entry =
|
|
3878
|
+
inboundOnlyServer(ref: ChannelRef): string | undefined {
|
|
3879
|
+
const entry = this.entryAt(ref);
|
|
3735
3880
|
return entry && isInboundOnly(entry) ? entry.serverId : undefined;
|
|
3736
3881
|
}
|
|
3737
3882
|
|
|
3883
|
+
/** A channel's entry, by its server and id: the map is keyed by destinationKey. */
|
|
3884
|
+
private entryAt(ref: ChannelRef): ChannelEntry | undefined {
|
|
3885
|
+
return this.channels.get(destinationKey(ref));
|
|
3886
|
+
}
|
|
3887
|
+
|
|
3738
3888
|
/**
|
|
3739
|
-
*
|
|
3740
|
-
*
|
|
3889
|
+
* The registered channels a name given as a string can mean (a tool's
|
|
3890
|
+
* channelId, a `>>` target, a host's call): those whose id is the name and
|
|
3891
|
+
* those whose server-qualified name (qualifiedChannelName) is the name,
|
|
3892
|
+
* taken together. The qualified form is matched against the registered
|
|
3893
|
+
* pairs, never split at a colon, since ids hold colons (discord-mcpl's are
|
|
3894
|
+
* `discord:<guild>:<channel>`). So a name that two entries answer to, in
|
|
3895
|
+
* either form, means both of them, and a caller that has to pick one
|
|
3896
|
+
* refuses it with both as candidates rather than taking the first.
|
|
3741
3897
|
*/
|
|
3742
|
-
private
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3898
|
+
private entriesNamed(name: string): ChannelEntry[] {
|
|
3899
|
+
return [...this.channels.values()].filter(
|
|
3900
|
+
(e) => e.descriptor.id === name || qualifiedChannelName(refOf(e)) === name,
|
|
3901
|
+
);
|
|
3902
|
+
}
|
|
3903
|
+
|
|
3904
|
+
/** The channels a name given as a string means (entriesNamed), by ref:
|
|
3905
|
+
* for a caller that has to settle a host's or a tool's name itself. */
|
|
3906
|
+
channelsNamed(name: string): ChannelRef[] {
|
|
3907
|
+
return this.entriesNamed(name).map(refOf);
|
|
3908
|
+
}
|
|
3909
|
+
|
|
3910
|
+
/**
|
|
3911
|
+
* The name to show an agent for a channel: one that picks it alone, which
|
|
3912
|
+
* a channel tool's channelId takes back, and a `>>` target too where the
|
|
3913
|
+
* name is one (isProseTargetToken: a single token other than `skip_reply`,
|
|
3914
|
+
* which a `>>` line reads as private speech); proseTargetFor gives the
|
|
3915
|
+
* target to write. A registered
|
|
3916
|
+
* channel's is nameFor's: its id, or, where another server registers the
|
|
3917
|
+
* same id, its server-qualified name. A channel no string picks alone, as
|
|
3918
|
+
* where qualified names collide too, is shown by its id and its server, as
|
|
3919
|
+
* the refusals show it (channelOnServer), which no input takes for a name.
|
|
3920
|
+
* A channel no longer registered is shown by its id, or by its qualified
|
|
3921
|
+
* name, only where no registered channel takes that name, as a channel
|
|
3922
|
+
* tool reads it or a `>>` target does (by label or raw id too), so the
|
|
3923
|
+
* name can't send anything to another channel; else by its id and server.
|
|
3924
|
+
*/
|
|
3925
|
+
nameShown(ref: ChannelRef): string {
|
|
3926
|
+
const entry = this.entryAt(ref);
|
|
3927
|
+
if (entry) return this.nameFor(entry) ?? channelOnServer(ref);
|
|
3928
|
+
for (const name of [ref.channelId, qualifiedChannelName(ref)]) {
|
|
3929
|
+
if (this.entriesNamed(name).length === 0 && !('channelId' in this.resolveProseTarget(name))) return name;
|
|
3930
|
+
}
|
|
3931
|
+
return channelOnServer(ref);
|
|
3932
|
+
}
|
|
3933
|
+
|
|
3934
|
+
/**
|
|
3935
|
+
* A tool's refusal of a tuned-out channel, whose decision is the tune-out:
|
|
3936
|
+
* its cancel is what ends it (tuneOutEndText), and a decision recorded over
|
|
3937
|
+
* it would end it without that cancel, so the backlog it diverted would
|
|
3938
|
+
* never reach the agent.
|
|
3939
|
+
*/
|
|
3940
|
+
private tunedOutRefusal(tool: string, entry: ChannelEntry): ToolResult {
|
|
3941
|
+
return {
|
|
3942
|
+
success: false,
|
|
3943
|
+
isError: true,
|
|
3944
|
+
error:
|
|
3945
|
+
`Channel ${this.quotedShown(refOf(entry))} is tuned out: its messages go to the subconscious, and ${tool} ` +
|
|
3946
|
+
`doesn't change that. ${this.tuneOutEndText(entry, tool)}`,
|
|
3947
|
+
data: { refusal: 'tuned-out', channelId: entry.descriptor.id, serverId: entry.serverId },
|
|
3948
|
+
};
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3951
|
+
/**
|
|
3952
|
+
* How a tuned-out channel's tune-out ends, for the texts that tell an agent
|
|
3953
|
+
* channel_open doesn't change it: a tune_out cancel, by the name that
|
|
3954
|
+
* picks the channel (nameFor), or, where no name picks it alone, its own
|
|
3955
|
+
* end, since tune_out takes a name.
|
|
3956
|
+
* For channel_close, which refuses a tuned-out channel too, it adds that
|
|
3957
|
+
* the close comes after that.
|
|
3958
|
+
*/
|
|
3959
|
+
/** How this channel's tune-out ends, as tune_out's own refusals say it
|
|
3960
|
+
* (tuneOutEndText), or undefined for a channel no longer registered. */
|
|
3961
|
+
tuneOutEnd(ref: ChannelRef): string | undefined {
|
|
3962
|
+
const entry = this.entryAt(ref);
|
|
3963
|
+
return entry ? this.tuneOutEndText(entry) : undefined;
|
|
3964
|
+
}
|
|
3965
|
+
|
|
3966
|
+
private tuneOutEndText(entry: ChannelEntry, tool?: string): string {
|
|
3967
|
+
const close = tool === 'channel_close';
|
|
3968
|
+
return this.nameFor(entry) !== undefined
|
|
3969
|
+
? `Call tune_out with channelId ${this.quotedShown(refOf(entry))} and mode "cancel" for it to resume normal ` +
|
|
3970
|
+
`attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}`
|
|
3971
|
+
: 'No channelId names it alone, so tune_out can\'t cancel it: its tune-out ends by itself when its duration ' +
|
|
3972
|
+
'runs out, or at the first wake past its wake budget, and its backlog is delivered then.' +
|
|
3973
|
+
(close ? ' Once it has ended, channel_close can close it.' : '');
|
|
3974
|
+
}
|
|
3975
|
+
|
|
3976
|
+
/**
|
|
3977
|
+
* A channel's name as nameShown gives it, quoted for a sentence: the name
|
|
3978
|
+
* in quotes, or the id-and-server form, whose parts are quoted already.
|
|
3979
|
+
*/
|
|
3980
|
+
quotedShown(ref: ChannelRef): string {
|
|
3981
|
+
const shown = this.nameShown(ref);
|
|
3982
|
+
return shown === channelOnServer(ref) ? shown : JSON.stringify(shown);
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
/**
|
|
3986
|
+
* The string that names exactly this entry by entriesNamed: its id when no
|
|
3987
|
+
* other entry answers to that (the id is unique), else its server-qualified
|
|
3988
|
+
* name when no other entry answers to that, else undefined. The last can
|
|
3989
|
+
* happen only where qualified names collide, as server `a`'s channel
|
|
3990
|
+
* `b:room` and server `a:b`'s channel `room` do; such a channel is named
|
|
3991
|
+
* only by its server and id apart.
|
|
3992
|
+
*/
|
|
3993
|
+
private nameFor(entry: ChannelEntry): string | undefined {
|
|
3994
|
+
for (const name of [entry.descriptor.id, qualifiedChannelName(refOf(entry))]) {
|
|
3995
|
+
const named = this.entriesNamed(name);
|
|
3996
|
+
if (named.length === 1 && named[0] === entry) return name;
|
|
3747
3997
|
}
|
|
3748
3998
|
return undefined;
|
|
3749
3999
|
}
|
|
3750
4000
|
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
4001
|
+
/**
|
|
4002
|
+
* The refusal for a name that more than one registered channel answers
|
|
4003
|
+
* to (entriesNamed). Where each one has a name that picks it alone
|
|
4004
|
+
* (nameFor), as two servers' channels with one id do, it lists those.
|
|
4005
|
+
* Where one has none, the name reads two ways, as server `a`'s `b:room`
|
|
4006
|
+
* and server `a:b`'s `room` both answer to `a:b:room`, and the refusal
|
|
4007
|
+
* gives a line per channel, each with its own route, or, where the tool
|
|
4008
|
+
* has none to it, saying that no channelId reaches it alone.
|
|
4009
|
+
* `takesServerId` says whether the tool also takes a serverId to pick
|
|
4010
|
+
* with; `nothing` says what didn't happen, "nothing ran" unless given.
|
|
4011
|
+
*/
|
|
4012
|
+
private sharedNameRefusal(
|
|
4013
|
+
name: string,
|
|
4014
|
+
entries: ChannelEntry[],
|
|
4015
|
+
opts: { takesServerId: boolean; nothing?: string },
|
|
4016
|
+
): string {
|
|
4017
|
+
const nothing = opts.nothing ?? 'nothing ran';
|
|
4018
|
+
const names = entries.map((e) => this.nameFor(e));
|
|
4019
|
+
if (names.every((n) => n !== undefined)) {
|
|
4020
|
+
const listed = names.map((n) => JSON.stringify(n)).join(', ');
|
|
4021
|
+
const servers = [...new Set(entries.map((e) => JSON.stringify(e.serverId)))];
|
|
4022
|
+
// One server's two channels can answer to one name too, as `s`'s
|
|
4023
|
+
// `s:room` and `room` both do to `s:room`; its server picks neither.
|
|
4024
|
+
if (servers.length === 1) {
|
|
4025
|
+
return `Channel ${JSON.stringify(name)} could mean more than one channel on server ${servers[0]} ` +
|
|
4026
|
+
`(${listed}), so ${nothing}. Call again with channelId set to one of them.`;
|
|
3766
4027
|
}
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
4028
|
+
// A serverId goes with the channel's own id, so it's offered only
|
|
4029
|
+
// where the name is every candidate's own id.
|
|
4030
|
+
const byServer = opts.takesServerId && entries.every((e) => e.descriptor.id === name)
|
|
4031
|
+
? `, or with serverId set to ${servers.join(' or ')}`
|
|
4032
|
+
: '';
|
|
4033
|
+
return `Channel ${JSON.stringify(name)} is on more than one server (${listed}), so ${nothing}.` +
|
|
4034
|
+
` Call again with channelId set to one of them${byServer}.`;
|
|
3772
4035
|
}
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
4036
|
+
const lines = entries.map((e, i) => {
|
|
4037
|
+
const where = channelOnServer(refOf(e));
|
|
4038
|
+
const own = names[i];
|
|
4039
|
+
if (own === undefined && !opts.takesServerId) return `- ${where}: no channelId reaches it alone.`;
|
|
4040
|
+
return own !== undefined
|
|
4041
|
+
? `- ${where}: call again with channelId ${JSON.stringify(own)}.`
|
|
4042
|
+
: `- ${where}: call again with channelId ${JSON.stringify(e.descriptor.id)} and serverId ${JSON.stringify(e.serverId)}.`;
|
|
4043
|
+
});
|
|
4044
|
+
const count = entries.length === 2 ? 'either of two channels' : `any of ${entries.length} channels`;
|
|
4045
|
+
return `Channel ${JSON.stringify(name)} could mean ${count}, so ${nothing}:\n${lines.join('\n')}`;
|
|
4046
|
+
}
|
|
4047
|
+
|
|
4048
|
+
/**
|
|
4049
|
+
* The refusal for a name more than one registered channel answers to, for
|
|
4050
|
+
* a tool outside the registry whose schema has no serverId (tune_out, the
|
|
4051
|
+
* subconscious's cancel_tuneout and deliver_summary), or undefined when the
|
|
4052
|
+
* name means one channel or none. `nothing` says what didn't happen,
|
|
4053
|
+
* "nothing ran" unless given.
|
|
4054
|
+
*/
|
|
4055
|
+
sharedNameRefusalFor(name: string, nothing?: string): string | undefined {
|
|
4056
|
+
const entries = this.entriesNamed(name);
|
|
4057
|
+
return entries.length > 1 ? this.sharedNameRefusal(name, entries, { takesServerId: false, nothing }) : undefined;
|
|
4058
|
+
}
|
|
4059
|
+
|
|
4060
|
+
/**
|
|
4061
|
+
* Where a channel id given with a server that doesn't have it is, for that
|
|
4062
|
+
* refusal: the servers that register the id, each a serverId to call again
|
|
4063
|
+
* with, and leaving serverId out only where the id alone then names one
|
|
4064
|
+
* channel (entriesNamed), since another channel's qualified name can be
|
|
4065
|
+
* the same string. For a publish, whose schema has no serverId
|
|
4066
|
+
* (`takesServerId` false: a channel_publish given one the agent added, or
|
|
4067
|
+
* a host's publishForAgent given a ref), where the id is, by the names its
|
|
4068
|
+
* channelId takes (nameFor), and no advice to post there, since each is
|
|
4069
|
+
* another destination, perhaps another community's.
|
|
4070
|
+
*/
|
|
4071
|
+
private elsewhereText(channelId: string, takesServerId = true): string {
|
|
4072
|
+
const elsewhere = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
|
|
4073
|
+
const named = this.entriesNamed(channelId);
|
|
4074
|
+
const alone = named.length === 1;
|
|
4075
|
+
if (elsewhere.length === 0) {
|
|
4076
|
+
// Such as a server-qualified name given with serverId too: alone, it
|
|
4077
|
+
// names its channel, which may be another server's than the one given,
|
|
4078
|
+
// so the text says whose.
|
|
4079
|
+
if (!alone) return 'channel_list shows the channels and their servers.';
|
|
4080
|
+
const one = named[0]!;
|
|
4081
|
+
const names = `${JSON.stringify(channelId)} alone names channel ${JSON.stringify(one.descriptor.id)} on server `
|
|
4082
|
+
+ JSON.stringify(one.serverId);
|
|
4083
|
+
return takesServerId ? `${names}: for that one, call again with serverId left out.` : `${names}.`;
|
|
3778
4084
|
}
|
|
3779
|
-
|
|
4085
|
+
if (!takesServerId) {
|
|
4086
|
+
// Each is another destination, perhaps another community's, so the
|
|
4087
|
+
// text names where the id is, each place by the name its channelId
|
|
4088
|
+
// takes, and leaves the choice to the caller. A place no channelId
|
|
4089
|
+
// reaches alone says so.
|
|
4090
|
+
const places = elsewhere.map((e) => {
|
|
4091
|
+
const at = `on server ${JSON.stringify(e.serverId)}`;
|
|
4092
|
+
const name = this.nameFor(e);
|
|
4093
|
+
return name === undefined ? `${at} (no channelId reaches it alone)` : `${at} as ${JSON.stringify(name)}`;
|
|
4094
|
+
});
|
|
4095
|
+
return `It's ${places.length === 1 ? places[0] : `${places.slice(0, -1).join(', ')} and ${places.at(-1)}`}.`;
|
|
4096
|
+
}
|
|
4097
|
+
const servers = elsewhere.map((e) => JSON.stringify(e.serverId));
|
|
4098
|
+
const leaveOut = alone ? ', or leave serverId out' : '';
|
|
4099
|
+
return servers.length === 1
|
|
4100
|
+
? `It's on server ${servers[0]}: call again with serverId ${servers[0]}${leaveOut}.`
|
|
4101
|
+
: `It's on servers ${servers.join(', ')}: call again with serverId set to one of them.`;
|
|
3780
4102
|
}
|
|
3781
4103
|
|
|
3782
4104
|
/**
|
|
3783
|
-
*
|
|
4105
|
+
* The channel a channel tool names: the channel with id `channelId` on
|
|
4106
|
+
* `serverId` when the call gives a serverId, the exact pair, as every
|
|
4107
|
+
* selector that gives a server reads it; otherwise the one channel its
|
|
4108
|
+
* name means (entriesNamed), which may be server-qualified. A name more
|
|
4109
|
+
* than one channel answers to is refused with them as candidates, never
|
|
4110
|
+
* resolved to the first. Its callers are the tools that take a serverId
|
|
4111
|
+
* (channel_open, channel_close, channel_decline), so the refusal offers
|
|
4112
|
+
* one. `own` is the calling turn's own channels, for an agent's call made
|
|
4113
|
+
* inside it (ChannelToolOrigin.own).
|
|
3784
4114
|
*/
|
|
3785
|
-
private
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
4115
|
+
private resolveToolChannelEntry(
|
|
4116
|
+
channelId: string,
|
|
4117
|
+
serverId?: string,
|
|
4118
|
+
own?: readonly ChannelRef[],
|
|
4119
|
+
): { entry?: ChannelEntry; error?: string } {
|
|
4120
|
+
// Inside the calling turn, a name one of the turn's own channels answers
|
|
4121
|
+
// to by its id means that channel, as a `>>` target does (ownChannelNamed):
|
|
4122
|
+
// once its server no longer registers it, the call is refused, with no
|
|
4123
|
+
// route to another server's channel with the id.
|
|
4124
|
+
const mine = serverId ? undefined : this.ownChannelNamed(channelId, own);
|
|
4125
|
+
if (mine && 'neither' in mine) return { error: this.ownNeitherRefusal(channelId, mine.neither, 'ran') };
|
|
4126
|
+
if (mine) {
|
|
4127
|
+
const entry = this.entryAt(mine.channel);
|
|
4128
|
+
return entry ? { entry } : { error: this.ownChannelGoneRefusal(channelId, mine.channel, 'other') };
|
|
3790
4129
|
}
|
|
3791
|
-
|
|
4130
|
+
if (serverId) {
|
|
4131
|
+
const entry = this.entryAt({ serverId, channelId });
|
|
4132
|
+
if (entry) return { entry };
|
|
4133
|
+
return { error: `No channel "${channelId}" is on server "${serverId}", so nothing ran. ${this.elsewhereText(channelId)}` };
|
|
4134
|
+
}
|
|
4135
|
+
const matches = this.entriesNamed(channelId);
|
|
4136
|
+
if (matches.length === 0) {
|
|
4137
|
+
return { error: `No channel has the id "${channelId}", so nothing ran. channel_list shows the channel ids.` };
|
|
4138
|
+
}
|
|
4139
|
+
if (matches.length > 1) return { error: this.sharedNameRefusal(channelId, matches, { takesServerId: true }) };
|
|
4140
|
+
return { entry: matches[0] };
|
|
3792
4141
|
}
|
|
3793
4142
|
|
|
3794
4143
|
// ==========================================================================
|
|
@@ -3798,6 +4147,11 @@ export class ChannelRegistry {
|
|
|
3798
4147
|
private handleToolList(): ToolResult {
|
|
3799
4148
|
const allChannels: Array<{
|
|
3800
4149
|
id: string;
|
|
4150
|
+
/** The name that picks the channel alone (nameFor), its server-qualified
|
|
4151
|
+
* id, where its id names another channel too, as where another server
|
|
4152
|
+
* registers the same id. A channel no string picks alone has none; a
|
|
4153
|
+
* channel tool that takes serverId reaches it by its id and server. */
|
|
4154
|
+
name?: string;
|
|
3801
4155
|
type: string;
|
|
3802
4156
|
label: string;
|
|
3803
4157
|
direction: string;
|
|
@@ -3806,9 +4160,18 @@ export class ChannelRegistry {
|
|
|
3806
4160
|
serverId: string;
|
|
3807
4161
|
}> = [];
|
|
3808
4162
|
|
|
4163
|
+
// How many registered channels answer to each id and server-qualified
|
|
4164
|
+
// id, in one pass, so each entry's name is nameFor's (the one of those
|
|
4165
|
+
// that picks it alone) without a scan of every channel per entry.
|
|
4166
|
+
const answering = new Map<string, number>();
|
|
4167
|
+
for (const e of this.channels.values()) {
|
|
4168
|
+
for (const n of [e.descriptor.id, qualifiedChannelName(refOf(e))]) answering.set(n, (answering.get(n) ?? 0) + 1);
|
|
4169
|
+
}
|
|
3809
4170
|
for (const entry of this.channels.values()) {
|
|
4171
|
+
const name = [entry.descriptor.id, qualifiedChannelName(refOf(entry))].find((n) => answering.get(n) === 1);
|
|
3810
4172
|
allChannels.push({
|
|
3811
4173
|
id: entry.descriptor.id,
|
|
4174
|
+
...(name !== undefined && name !== entry.descriptor.id ? { name } : {}),
|
|
3812
4175
|
type: entry.descriptor.type,
|
|
3813
4176
|
label: entry.descriptor.label,
|
|
3814
4177
|
direction: entry.descriptor.direction,
|
|
@@ -3930,7 +4293,7 @@ export class ChannelRegistry {
|
|
|
3930
4293
|
// and lifted nothing. The lift is ensureInitialDesiredState's own,
|
|
3931
4294
|
// which leaves every real decision alone. One that can't be recorded
|
|
3932
4295
|
// leaves the default, closed.
|
|
3933
|
-
const current = this.
|
|
4296
|
+
const current = this.entryAt(refOf(entry));
|
|
3934
4297
|
let selectedAgain: Exclude<SelectedForOpening, 'unrecorded'> | undefined;
|
|
3935
4298
|
if (current) {
|
|
3936
4299
|
try {
|
|
@@ -4004,7 +4367,10 @@ export class ChannelRegistry {
|
|
|
4004
4367
|
* docs/explicit-prose-routing.md) to a registered channel.
|
|
4005
4368
|
*
|
|
4006
4369
|
* Accepted spellings, tried in order:
|
|
4007
|
-
* 1. exact descriptor id (`discord:guild:123
|
|
4370
|
+
* 1. exact descriptor id (`discord:guild:123`), or its server-qualified
|
|
4371
|
+
* name (`a:discord:guild:123`), as entriesNamed matches them; a name
|
|
4372
|
+
* two channels answer to, such as an id two servers register, is
|
|
4373
|
+
* refused with both as candidates
|
|
4008
4374
|
* 2. exact raw server-internal id (`address.channelId`)
|
|
4009
4375
|
* 3. `#label` / bare label — case-insensitive match on descriptor label
|
|
4010
4376
|
* with any leading '#' stripped from both sides
|
|
@@ -4019,34 +4385,172 @@ export class ChannelRegistry {
|
|
|
4019
4385
|
* names the channel as `refusedChannelId`, so a caller can tell a refused
|
|
4020
4386
|
* channel from a name that resolved to nothing. Without it, as for reading
|
|
4021
4387
|
* a channel's history, any registered channel resolves.
|
|
4388
|
+
*
|
|
4389
|
+
* `own` is the turn's own channels, for a target its speech names: a name
|
|
4390
|
+
* one of them answers to by its id means that channel, on its server,
|
|
4391
|
+
* before any other spelling (ownChannelNamed).
|
|
4022
4392
|
*/
|
|
4023
4393
|
resolveProseTarget(
|
|
4024
4394
|
spec: string,
|
|
4025
|
-
opts?: { forSpeech?: boolean },
|
|
4026
|
-
):
|
|
4027
|
-
const
|
|
4395
|
+
opts?: { forSpeech?: boolean; own?: readonly ChannelRef[] },
|
|
4396
|
+
): PublishDestination | { error: string; candidates?: string[]; refusedChannelId?: string } {
|
|
4397
|
+
const own = this.ownChannelNamed(spec.trim(), opts?.own);
|
|
4398
|
+
let resolved: PublishDestination | { error: string; candidates?: string[] };
|
|
4399
|
+
if (own === undefined) {
|
|
4400
|
+
resolved = this.resolveTargetByName(spec);
|
|
4401
|
+
} else if ('neither' in own) {
|
|
4402
|
+
resolved = { error: `"${spec.trim()}" is the id of this turn's channels on more than one server`, candidates: this.ownCandidates(own.neither).map((c) => c.text) };
|
|
4403
|
+
} else {
|
|
4404
|
+
const entry = this.entryAt(own.channel);
|
|
4405
|
+
resolved = entry
|
|
4406
|
+
? { channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label }
|
|
4407
|
+
: { error: `${this.ownChannelGoneText(spec.trim(), own.channel)}, and nothing can be sent there until that server registers it again` };
|
|
4408
|
+
}
|
|
4028
4409
|
if (!opts?.forSpeech || 'error' in resolved) return resolved;
|
|
4029
|
-
const entry = this.
|
|
4410
|
+
const entry = this.entryAt(resolved);
|
|
4030
4411
|
return entry && isInboundOnly(entry)
|
|
4031
|
-
? { error: `${resolved
|
|
4412
|
+
? { error: `${this.nameShown(resolved)} is ${inboundOnlyReason(entry)}`, refusedChannelId: resolved.channelId }
|
|
4032
4413
|
: resolved;
|
|
4033
4414
|
}
|
|
4034
4415
|
|
|
4416
|
+
/**
|
|
4417
|
+
* The turn's own channel a name means: where the name is the id of one of
|
|
4418
|
+
* the turn's own channels, a channel that has been its trigger or its
|
|
4419
|
+
* locus, that channel, on its server, whether or not that server still
|
|
4420
|
+
* registers it. A message's
|
|
4421
|
+
* reply goes where the message came from, so another server that
|
|
4422
|
+
* registers the id, or takes it once the message's server withdrew it,
|
|
4423
|
+
* isn't reached through the name. Where the turn's channels with that id
|
|
4424
|
+
* are on more than one server, the name means neither. Undefined where no
|
|
4425
|
+
* channel of the turn's has that id.
|
|
4426
|
+
*/
|
|
4427
|
+
private ownChannelNamed(
|
|
4428
|
+
name: string,
|
|
4429
|
+
own: readonly ChannelRef[] | undefined,
|
|
4430
|
+
): { channel: ChannelRef } | { neither: ChannelRef[] } | undefined {
|
|
4431
|
+
const named: ChannelRef[] = [];
|
|
4432
|
+
for (const c of own ?? []) {
|
|
4433
|
+
if (c.channelId === name && !named.some((n) => sameChannel(n, c))) named.push(c);
|
|
4434
|
+
}
|
|
4435
|
+
if (named.length === 0) return undefined;
|
|
4436
|
+
return named.length === 1 ? { channel: named[0]! } : { neither: named };
|
|
4437
|
+
}
|
|
4438
|
+
|
|
4439
|
+
/** Why a name the turn's own channel answers to (ownChannelNamed) reaches
|
|
4440
|
+
* nothing once that channel's server no longer registers it. It offers no
|
|
4441
|
+
* other channel with the id, since that's another server's. */
|
|
4442
|
+
private ownChannelGoneText(name: string, channel: ChannelRef): string {
|
|
4443
|
+
return `"${name}" is this turn's channel on server "${channel.serverId}", which no longer registers it`;
|
|
4444
|
+
}
|
|
4445
|
+
|
|
4446
|
+
/**
|
|
4447
|
+
* A name the calling turn's own channels answer to by their id, for a tool
|
|
4448
|
+
* outside the registry that takes a channel's name (tune_out, and the
|
|
4449
|
+
* subconscious's cancel_tuneout, speak_in_channel and deliver_summary),
|
|
4450
|
+
* read as the registry's channel tools read it: the turn's channel, or a
|
|
4451
|
+
* refusal where its server no longer registers it, or where the name is
|
|
4452
|
+
* the id of the turn's channels on more than one server. Undefined where
|
|
4453
|
+
* none of the turn's channels has that id. `tool` words the refusal for
|
|
4454
|
+
* what the tool would have done: posted (speak_in_channel), delivered
|
|
4455
|
+
* (deliver_summary) or run (the others).
|
|
4456
|
+
*/
|
|
4457
|
+
ownChannelFor(
|
|
4458
|
+
name: string,
|
|
4459
|
+
own: readonly ChannelRef[] | undefined,
|
|
4460
|
+
tool: 'publish' | 'deliver' | 'other' = 'other',
|
|
4461
|
+
): { channel: ChannelRef } | { error: string } | undefined {
|
|
4462
|
+
const mine = this.ownChannelNamed(name, own);
|
|
4463
|
+
if (!mine) return undefined;
|
|
4464
|
+
if ('neither' in mine) {
|
|
4465
|
+
const done = tool === 'publish' ? 'was posted' : tool === 'deliver' ? 'was delivered' : 'ran';
|
|
4466
|
+
return { error: this.ownNeitherRefusal(name, mine.neither, done) };
|
|
4467
|
+
}
|
|
4468
|
+
return this.entryAt(mine.channel) ? { channel: mine.channel } : { error: this.ownChannelGoneRefusal(name, mine.channel, tool) };
|
|
4469
|
+
}
|
|
4470
|
+
|
|
4471
|
+
/** A refusal of a name the turn's own channel answers to, once its server
|
|
4472
|
+
* no longer registers it: nothing was posted (channel_publish,
|
|
4473
|
+
* speak_in_channel), delivered (deliver_summary) or ran (the other channel
|
|
4474
|
+
* tools, tune_out, cancel_tuneout), and nothing can be until it does
|
|
4475
|
+
* again. */
|
|
4476
|
+
private ownChannelGoneRefusal(name: string, channel: ChannelRef, tool: 'publish' | 'deliver' | 'other'): string {
|
|
4477
|
+
const outcome = tool === 'publish' ? 'nothing was posted, and nothing can be'
|
|
4478
|
+
: tool === 'deliver' ? 'nothing was delivered, and nothing can be' : 'nothing ran, and nothing can run on it';
|
|
4479
|
+
return `${this.ownChannelGoneText(name, channel)}, so ${outcome} until that server registers it again.`;
|
|
4480
|
+
}
|
|
4481
|
+
|
|
4482
|
+
/**
|
|
4483
|
+
* The turn's channels a name means neither of (ownChannelNamed), each by a
|
|
4484
|
+
* string that reaches it outside the turn's rule: its server-qualified
|
|
4485
|
+
* name, where that picks it alone, since its id is the name refused. One
|
|
4486
|
+
* no string picks alone, and one its server no longer registers, is named
|
|
4487
|
+
* by its id and server, saying so; `reaches` says which a name reaches.
|
|
4488
|
+
*/
|
|
4489
|
+
private ownCandidates(channels: readonly ChannelRef[]): Array<{ text: string; reaches: boolean }> {
|
|
4490
|
+
return channels.map((c) => {
|
|
4491
|
+
const entry = this.entryAt(c);
|
|
4492
|
+
if (!entry) return { text: `${channelOnServer(c)} (its server no longer registers it)`, reaches: false };
|
|
4493
|
+
const qualified = qualifiedChannelName(c);
|
|
4494
|
+
const picked = this.entriesNamed(qualified);
|
|
4495
|
+
return picked.length === 1 && picked[0] === entry
|
|
4496
|
+
? { text: qualified, reaches: true }
|
|
4497
|
+
: { text: `${channelOnServer(c)} (no channelId reaches it alone)`, reaches: false };
|
|
4498
|
+
});
|
|
4499
|
+
}
|
|
4500
|
+
|
|
4501
|
+
/** A tool's refusal of a name the turn's channels on more than one server
|
|
4502
|
+
* answer to by their id (ownChannelNamed's neither). */
|
|
4503
|
+
private ownNeitherRefusal(name: string, channels: readonly ChannelRef[], done: string): string {
|
|
4504
|
+
const candidates = this.ownCandidates(channels);
|
|
4505
|
+
const named = candidates.filter((c) => c.reaches).map((c) => JSON.stringify(c.text));
|
|
4506
|
+
const pick = named.length === candidates.length ? ' Call again with channelId set to one of them.'
|
|
4507
|
+
: named.length === 1 ? ` Call again with channelId ${named[0]}.`
|
|
4508
|
+
: named.length > 1 ? ` Call again with channelId set to one of ${named.join(', ')}.` : '';
|
|
4509
|
+
const listed = candidates.map((c) => (c.reaches ? JSON.stringify(c.text) : c.text)).join(', ');
|
|
4510
|
+
return `"${name}" is the id of this turn's channels on more than one server (${listed}), so nothing ${done}.${pick}`;
|
|
4511
|
+
}
|
|
4512
|
+
|
|
4035
4513
|
private resolveTargetByName(
|
|
4036
4514
|
spec: string,
|
|
4037
|
-
):
|
|
4515
|
+
): PublishDestination | { error: string; candidates?: string[] } {
|
|
4038
4516
|
const entries = [...this.channels.values()];
|
|
4039
4517
|
const trimmed = spec.trim();
|
|
4040
4518
|
if (!trimmed) return { error: 'empty target' };
|
|
4519
|
+
const hit = (e: ChannelEntry): PublishDestination =>
|
|
4520
|
+
({ channelId: e.descriptor.id, serverId: e.serverId, label: e.descriptor.label });
|
|
4521
|
+
// A candidate is named by the token that picks it alone (nameFor), so
|
|
4522
|
+
// two servers' channels with one id or one label can be told apart, or,
|
|
4523
|
+
// where no string picks it alone, by its id and its server.
|
|
4524
|
+
const token = (e: ChannelEntry): string => this.nameFor(e) ?? channelOnServer(refOf(e));
|
|
4525
|
+
// A DM is shown by its label, the person, where that label, given as a
|
|
4526
|
+
// target, picks that DM. It doesn't where another channel answers to that
|
|
4527
|
+
// label too, as two servers' DMs with one person do (a label is matched
|
|
4528
|
+
// ignoring case, so "DM: Antra" and "DM: antra" both answer), nor where
|
|
4529
|
+
// an id, a raw id or another label outranks it, so a bounce's example or
|
|
4530
|
+
// a resend's destination never names another channel. Those are shown by
|
|
4531
|
+
// their tokens instead. A label read as a mention or an @name would come
|
|
4532
|
+
// back here, so it's shown by its token too.
|
|
4533
|
+
const labelPicks = (e: ChannelEntry, label: string): boolean => {
|
|
4534
|
+
if (/^@|^<@!?\d+>$/.test(label.trim())) return false;
|
|
4535
|
+
const r = this.resolveTargetByName(label);
|
|
4536
|
+
return 'channelId' in r && sameChannel(r, refOf(e));
|
|
4537
|
+
};
|
|
4538
|
+
const dmShown = (list: ChannelEntry[]): string[] => list.map((e) => {
|
|
4539
|
+
const label = e.descriptor.label ?? e.descriptor.id;
|
|
4540
|
+
return labelPicks(e, label) ? label : token(e);
|
|
4541
|
+
});
|
|
4041
4542
|
|
|
4042
|
-
|
|
4543
|
+
// A channel id, or its server-qualified name (entriesNamed).
|
|
4544
|
+
const exact = this.entriesNamed(trimmed);
|
|
4043
4545
|
if (exact.length === 1) {
|
|
4044
|
-
return
|
|
4546
|
+
return hit(exact[0]!);
|
|
4045
4547
|
}
|
|
4046
4548
|
if (exact.length > 1) {
|
|
4047
4549
|
return {
|
|
4048
|
-
error:
|
|
4049
|
-
|
|
4550
|
+
error: new Set(exact.map((e) => e.serverId)).size > 1
|
|
4551
|
+
? `channel id "${trimmed}" is ambiguous across servers`
|
|
4552
|
+
: `channel id "${trimmed}" names more than one channel on server "${exact[0]!.serverId}"`,
|
|
4553
|
+
candidates: exact.map(token),
|
|
4050
4554
|
};
|
|
4051
4555
|
}
|
|
4052
4556
|
|
|
@@ -4054,12 +4558,12 @@ export class ChannelRegistry {
|
|
|
4054
4558
|
(e) => (e.descriptor.address as { channelId?: string } | undefined)?.channelId === trimmed,
|
|
4055
4559
|
);
|
|
4056
4560
|
if (raw.length === 1) {
|
|
4057
|
-
return
|
|
4561
|
+
return hit(raw[0]!);
|
|
4058
4562
|
}
|
|
4059
4563
|
if (raw.length > 1) {
|
|
4060
4564
|
return {
|
|
4061
4565
|
error: `raw id "${trimmed}" is ambiguous`,
|
|
4062
|
-
candidates: raw.map(
|
|
4566
|
+
candidates: raw.map(token),
|
|
4063
4567
|
};
|
|
4064
4568
|
}
|
|
4065
4569
|
|
|
@@ -4090,11 +4594,15 @@ export class ChannelRegistry {
|
|
|
4090
4594
|
const id = mention[1]!;
|
|
4091
4595
|
const byId = dms.filter((e) => dmMeta(e)?.recipientId === id);
|
|
4092
4596
|
if (byId.length === 1) {
|
|
4093
|
-
return
|
|
4597
|
+
return hit(byId[0]!);
|
|
4598
|
+
}
|
|
4599
|
+
if (byId.length > 1) {
|
|
4600
|
+
// One person's DMs on more than one server.
|
|
4601
|
+
return { error: `"${trimmed}" matches several DMs`, candidates: dmShown(byId) };
|
|
4094
4602
|
}
|
|
4095
4603
|
return {
|
|
4096
4604
|
error: `no registered DM matches the mention <@${id}>`,
|
|
4097
|
-
candidates: dms.
|
|
4605
|
+
candidates: dmShown(dms.slice(0, 6)),
|
|
4098
4606
|
};
|
|
4099
4607
|
}
|
|
4100
4608
|
|
|
@@ -4107,17 +4615,17 @@ export class ChannelRegistry {
|
|
|
4107
4615
|
return n.length >= 3 && (n.startsWith(name) || name.startsWith(n) || n.includes(name));
|
|
4108
4616
|
});
|
|
4109
4617
|
if (pool.length === 1) {
|
|
4110
|
-
return
|
|
4618
|
+
return hit(pool[0]!);
|
|
4111
4619
|
}
|
|
4112
4620
|
if (pool.length > 1) {
|
|
4113
4621
|
return {
|
|
4114
4622
|
error: `"${trimmed}" matches several DMs`,
|
|
4115
|
-
candidates: pool
|
|
4623
|
+
candidates: dmShown(pool),
|
|
4116
4624
|
};
|
|
4117
4625
|
}
|
|
4118
4626
|
return {
|
|
4119
4627
|
error: `no registered DM found for "${trimmed}" — for someone without a registered DM channel, use the send_dm tool`,
|
|
4120
|
-
...(dms.length ? { candidates: dms.
|
|
4628
|
+
...(dms.length ? { candidates: dmShown(dms.slice(0, 6)) } : {}),
|
|
4121
4629
|
};
|
|
4122
4630
|
}
|
|
4123
4631
|
|
|
@@ -4125,12 +4633,12 @@ export class ChannelRegistry {
|
|
|
4125
4633
|
(e) => norm(e.descriptor.label ?? '') === norm(trimmed),
|
|
4126
4634
|
);
|
|
4127
4635
|
if (byLabel.length === 1) {
|
|
4128
|
-
return
|
|
4636
|
+
return hit(byLabel[0]!);
|
|
4129
4637
|
}
|
|
4130
4638
|
if (byLabel.length > 1) {
|
|
4131
4639
|
return {
|
|
4132
4640
|
error: `label "${trimmed}" matches several channels`,
|
|
4133
|
-
candidates: byLabel.map(
|
|
4641
|
+
candidates: byLabel.map(token),
|
|
4134
4642
|
};
|
|
4135
4643
|
}
|
|
4136
4644
|
|
|
@@ -4142,12 +4650,12 @@ export class ChannelRegistry {
|
|
|
4142
4650
|
const nameOf = normalizeChannelLabelName;
|
|
4143
4651
|
const byName = entries.filter((e) => nameOf(e.descriptor.label ?? '') === norm(trimmed));
|
|
4144
4652
|
if (byName.length === 1) {
|
|
4145
|
-
return
|
|
4653
|
+
return hit(byName[0]!);
|
|
4146
4654
|
}
|
|
4147
4655
|
if (byName.length > 1) {
|
|
4148
4656
|
return {
|
|
4149
4657
|
error: `"${trimmed}" matches several channels`,
|
|
4150
|
-
candidates: byName.map((e) => `${e.descriptor.label} = ${e
|
|
4658
|
+
candidates: byName.map((e) => `${e.descriptor.label} = ${token(e)}`),
|
|
4151
4659
|
};
|
|
4152
4660
|
}
|
|
4153
4661
|
|
|
@@ -4156,7 +4664,7 @@ export class ChannelRegistry {
|
|
|
4156
4664
|
const near = entries
|
|
4157
4665
|
.filter((e) => norm(e.descriptor.label ?? '').includes(norm(trimmed)))
|
|
4158
4666
|
.slice(0, 5)
|
|
4159
|
-
.map((e) => `${e.descriptor.label} = ${e
|
|
4667
|
+
.map((e) => `${e.descriptor.label} = ${token(e)}`);
|
|
4160
4668
|
return { error: `no channel matches "${trimmed}"`, ...(near.length ? { candidates: near } : {}) };
|
|
4161
4669
|
}
|
|
4162
4670
|
|
|
@@ -4167,18 +4675,19 @@ export class ChannelRegistry {
|
|
|
4167
4675
|
* a space can't be quoted verbatim: `>>#DM: alice` parses as target `#DM:`
|
|
4168
4676
|
* plus body `alice …`, and `>>#fable (antra's server)` delivers
|
|
4169
4677
|
* `(antra's server)` as text. Tried in order: `@name` for a DM, `#label`,
|
|
4170
|
-
* `#name` (label without its server suffix), the
|
|
4678
|
+
* `#name` (label without its server suffix), the name that picks the
|
|
4679
|
+
* channel alone (nameFor): its id, or, where another server registers the
|
|
4680
|
+
* same id, its server-qualified name; and last its server-qualified name,
|
|
4681
|
+
* for a channel whose id is no target (isProseTargetToken), as
|
|
4682
|
+
* `skip_reply` isn't.
|
|
4171
4683
|
*
|
|
4172
|
-
* Undefined when there is no safe token: the channel isn't registered
|
|
4173
|
-
*
|
|
4174
|
-
*
|
|
4175
|
-
* channel by id alone, and ids are unique only within a connection, so a
|
|
4176
|
-
* shared id could route the reply through the wrong server.
|
|
4684
|
+
* Undefined when there is no safe token: the channel isn't registered, or
|
|
4685
|
+
* no candidate is whitespace-free and resolves back to this channel, on
|
|
4686
|
+
* this server.
|
|
4177
4687
|
*/
|
|
4178
|
-
proseTargetFor(
|
|
4179
|
-
const
|
|
4180
|
-
|
|
4181
|
-
if (!entry || sameId.length > 1) return undefined;
|
|
4688
|
+
proseTargetFor(ref: ChannelRef): string | undefined {
|
|
4689
|
+
const entry = this.entryAt(ref);
|
|
4690
|
+
if (!entry) return undefined;
|
|
4182
4691
|
const d = entry.descriptor;
|
|
4183
4692
|
const label = d.label ?? '';
|
|
4184
4693
|
const meta = d.metadata as { channelType?: string; recipientName?: string } | undefined;
|
|
@@ -4188,23 +4697,143 @@ export class ChannelRegistry {
|
|
|
4188
4697
|
const candidates = [
|
|
4189
4698
|
...(isDm && dmName ? [`@${dmName}`] : []),
|
|
4190
4699
|
...(bare ? [`#${bare}`, `#${bare.replace(/\s*\([^)]*\)\s*$/, '')}`] : []),
|
|
4191
|
-
|
|
4700
|
+
...(this.nameFor(entry) !== undefined ? [this.nameFor(entry)!] : []),
|
|
4701
|
+
qualifiedChannelName(ref),
|
|
4192
4702
|
];
|
|
4193
4703
|
for (const c of candidates) {
|
|
4194
|
-
if (
|
|
4704
|
+
if (!isProseTargetToken(c) || c === '#') continue;
|
|
4195
4705
|
const r = this.resolveProseTarget(c, { forSpeech: true });
|
|
4196
|
-
if ('channelId' in r && r
|
|
4706
|
+
if ('channelId' in r && sameChannel(r, ref)) return c;
|
|
4197
4707
|
}
|
|
4198
4708
|
return undefined;
|
|
4199
4709
|
}
|
|
4200
4710
|
|
|
4711
|
+
/** Whether the decision on record is a close the agent or a module made
|
|
4712
|
+
* (isCloseDecision). */
|
|
4713
|
+
private closeDecided(ref: ChannelRef): boolean {
|
|
4714
|
+
const current = this.desiredStates.get(this.lifecycleKey(ref.serverId, ref.channelId));
|
|
4715
|
+
return current?.state === 'closed' && isCloseDecision(current.source);
|
|
4716
|
+
}
|
|
4717
|
+
|
|
4718
|
+
/**
|
|
4719
|
+
* Whether the agent's words delivered into this channel open it first, by
|
|
4720
|
+
* publish() or after an explicit send (openIfClosedForSend): one that isn't
|
|
4721
|
+
* open, or whose close stands (closeStands: a close that failed may have
|
|
4722
|
+
* left its transport open), unless speech leaves it as it stands
|
|
4723
|
+
* (speechLeavesAsItStands). Where nobody decided (no record, a pure
|
|
4724
|
+
* default) or a machine closed the channel (subscription-gc,
|
|
4725
|
+
* housekeeping), it opens, as it always did, and so does one decided open
|
|
4726
|
+
* whose transport isn't confirmed. A tuned-out channel whose transport
|
|
4727
|
+
* isn't open is opened for its traffic, and its tune-out stays
|
|
4728
|
+
* (openChannelNow). A server that isn't up isn't one this host can't
|
|
4729
|
+
* direct, though a drop empties its grant until its next exchange, so
|
|
4730
|
+
* speech there is refused as it always was: by the open's failure, which
|
|
4731
|
+
* says the server is connecting or reconnecting, or, where its grant is
|
|
4732
|
+
* empty, by publish()'s grant check before any open.
|
|
4733
|
+
*/
|
|
4734
|
+
speechOpens(ref: ChannelRef): boolean {
|
|
4735
|
+
const entry = this.entryAt(ref);
|
|
4736
|
+
return !!entry && (!entry.open || this.closeStands(entry)) && !this.speechLeavesAsItStands(entry);
|
|
4737
|
+
}
|
|
4738
|
+
|
|
4739
|
+
/** Whether speech leaves this registration as it stands, never opening
|
|
4740
|
+
* it, and words go into it as it is: the decision on record is a close
|
|
4741
|
+
* the agent made itself (channel_close, channel_decline) or a module made
|
|
4742
|
+
* (MODULE_CLOSE_SOURCE), which stands against the agent's own speech
|
|
4743
|
+
* whatever the channel's transport is; or its server is up and this host
|
|
4744
|
+
* doesn't direct its lifecycle (lifecycleWithheld), so nothing opens
|
|
4745
|
+
* there. A wait or an open that failed gives way to such a registration,
|
|
4746
|
+
* as when the agent closed the channel while the words waited, since
|
|
4747
|
+
* there's nothing for the words to wait for (refusalGivesWay). */
|
|
4748
|
+
private speechLeavesAsItStands(entry: ChannelEntry | undefined): entry is ChannelEntry {
|
|
4749
|
+
if (!entry) return false;
|
|
4750
|
+
return this.closeDecided(refOf(entry)) || this.lifecycleWithheld(entry.serverId);
|
|
4751
|
+
}
|
|
4752
|
+
|
|
4753
|
+
/** Whether words a wait or an open refused go into the channel after all:
|
|
4754
|
+
* its registration `now` is one speech leaves as it stands
|
|
4755
|
+
* (speechLeavesAsItStands), such as one the agent closed or declined
|
|
4756
|
+
* while the words waited, and the failure isn't convergence that never
|
|
4757
|
+
* established its target (convergeFailure). A registration that went
|
|
4758
|
+
* still fails them, and publish() refuses one the server last answered
|
|
4759
|
+
* for another channel (answeredElsewhere), whenever that happened. */
|
|
4760
|
+
private refusalGivesWay(err: unknown, now: ChannelEntry | undefined): now is ChannelEntry {
|
|
4761
|
+
return this.speechLeavesAsItStands(now) && !convergeFailure(err);
|
|
4762
|
+
}
|
|
4763
|
+
|
|
4764
|
+
/** Whether this host doesn't direct the server's channel lifecycle: the
|
|
4765
|
+
* server is up, and its grant lacks channels.lifecycle, so nothing opens
|
|
4766
|
+
* or closes its channels. A server that isn't up has an empty grant from
|
|
4767
|
+
* a drop until its next exchange, which says nothing yet of what it
|
|
4768
|
+
* grants, so it isn't counted. */
|
|
4769
|
+
lifecycleWithheld(serverId: string): boolean {
|
|
4770
|
+
return !this.directsLifecycle(serverId) && this.serverReach(serverId) === 'up';
|
|
4771
|
+
}
|
|
4772
|
+
|
|
4773
|
+
/** Whether the agent's words go into this channel and leave it closed: it
|
|
4774
|
+
* isn't open at the transport, and speech leaves it as it stands
|
|
4775
|
+
* (speechLeavesAsItStands: the agent's or a module's close, or a server
|
|
4776
|
+
* that's up without channels.lifecycle). False for a channel open at the
|
|
4777
|
+
* transport, whose traffic still flows, and for one speech opens. */
|
|
4778
|
+
speechLeavesClosed(ref: ChannelRef): boolean {
|
|
4779
|
+
const entry = this.entryAt(ref);
|
|
4780
|
+
return !!entry && !entry.open && this.speechLeavesAsItStands(entry);
|
|
4781
|
+
}
|
|
4782
|
+
|
|
4783
|
+
/**
|
|
4784
|
+
* How the agent kept this registered channel closed itself, if it did:
|
|
4785
|
+
* 'closed' for its channel_close, 'declined' for its channel_decline. Its
|
|
4786
|
+
* speech there leaves such a channel as it stands (speechOpens). A
|
|
4787
|
+
* channel no longer registered has nothing to keep closed, whatever its
|
|
4788
|
+
* record says, so it answers undefined.
|
|
4789
|
+
*/
|
|
4790
|
+
closedByAgent(ref: ChannelRef): 'closed' | 'declined' | undefined {
|
|
4791
|
+
if (!this.entryAt(ref)) return undefined;
|
|
4792
|
+
const current = this.desiredStates.get(this.lifecycleKey(ref.serverId, ref.channelId));
|
|
4793
|
+
if (current?.state !== 'closed') return undefined;
|
|
4794
|
+
if (current.source === 'agent-tool') return 'closed';
|
|
4795
|
+
if (current.source === 'invitation-declined') return 'declined';
|
|
4796
|
+
return undefined;
|
|
4797
|
+
}
|
|
4798
|
+
|
|
4799
|
+
/** The registered channels an explicit send tool may have delivered into,
|
|
4800
|
+
* from the id the tool reported or was given: a composite MCPL id, or the
|
|
4801
|
+
* raw server-internal id (`address.channelId`), since send tools receive
|
|
4802
|
+
* whatever the agent typed. Matches on `serverId` when it is given,
|
|
4803
|
+
* across every server otherwise. */
|
|
4804
|
+
private sendTargets(rawChannelId: string, serverId?: string): ChannelEntry[] {
|
|
4805
|
+
const matches = [...this.channels.values()].filter(
|
|
4806
|
+
(e) => e.descriptor.id === rawChannelId && (!serverId || e.serverId === serverId),
|
|
4807
|
+
);
|
|
4808
|
+
if (matches.length > 0) return matches;
|
|
4809
|
+
return [...this.channels.values()].filter(
|
|
4810
|
+
(e) =>
|
|
4811
|
+
(e.descriptor.address as { channelId?: string } | undefined)?.channelId === rawChannelId &&
|
|
4812
|
+
(!serverId || e.serverId === serverId),
|
|
4813
|
+
);
|
|
4814
|
+
}
|
|
4815
|
+
|
|
4816
|
+
/** The one registered channel an explicit send tool delivered into
|
|
4817
|
+
* (sendTargets), or undefined when none or more than one matches. A
|
|
4818
|
+
* lookup only, for what's said beside the send's answer before it's
|
|
4819
|
+
* queued; openIfClosedForSend decides whether the send opens it. */
|
|
4820
|
+
resolveSendTarget(rawChannelId: string, serverId?: string): ChannelRef | undefined {
|
|
4821
|
+
const matches = this.sendTargets(rawChannelId, serverId);
|
|
4822
|
+
return matches.length === 1 ? refOf(matches[0]!) : undefined;
|
|
4823
|
+
}
|
|
4824
|
+
|
|
4201
4825
|
/**
|
|
4202
|
-
* Open a channel because something was DELIVERED into it
|
|
4203
|
-
* tool
|
|
4204
|
-
*
|
|
4205
|
-
*
|
|
4206
|
-
*
|
|
4207
|
-
*
|
|
4826
|
+
* Open a channel because something was DELIVERED into it by an explicit
|
|
4827
|
+
* send tool, so typing indicators, reaction machinery and inbound
|
|
4828
|
+
* forwarding all come alive with the first outbound message, unless
|
|
4829
|
+
* speech leaves it as it stands (speechLeavesAsItStands: the agent closed
|
|
4830
|
+
* or declined it itself, a module closed it, or its server is up and this
|
|
4831
|
+
* host doesn't direct its lifecycle). Such a channel opens nothing, and
|
|
4832
|
+
* answers `stays-closed`, or `already-open` where its transport is open
|
|
4833
|
+
* with no close standing, whether the send found it so or it became so
|
|
4834
|
+
* while the send's wait or open was out.
|
|
4835
|
+
* Accepts composite MCPL ids or raw server-internal ids (sendTargets). An
|
|
4836
|
+
* `open-failed` answer says
|
|
4208
4837
|
* whether the open that failed was one that would have opened a channel
|
|
4209
4838
|
* not selected open (`opening`), as opposed to one reconfirming the
|
|
4210
4839
|
* transport of a channel already selected open or tuned out. It isn't one
|
|
@@ -4220,22 +4849,15 @@ export class ChannelRegistry {
|
|
|
4220
4849
|
rawChannelId: string,
|
|
4221
4850
|
serverId?: string,
|
|
4222
4851
|
): Promise<{
|
|
4223
|
-
status: 'opened' | 'already-open' | 'unknown-channel' | 'ambiguous' | 'open-failed';
|
|
4852
|
+
status: 'opened' | 'already-open' | 'stays-closed' | 'unknown-channel' | 'ambiguous' | 'open-failed';
|
|
4224
4853
|
channelId?: string;
|
|
4854
|
+
/** The channel's server, beside `channelId` whenever it names one. */
|
|
4855
|
+
serverId?: string;
|
|
4225
4856
|
label?: string;
|
|
4226
4857
|
opening?: boolean;
|
|
4227
4858
|
selectedForOpening?: SelectedForOpening;
|
|
4228
4859
|
}> {
|
|
4229
|
-
|
|
4230
|
-
(e) => e.descriptor.id === rawChannelId && (!serverId || e.serverId === serverId),
|
|
4231
|
-
);
|
|
4232
|
-
if (matches.length === 0) {
|
|
4233
|
-
matches = [...this.channels.values()].filter(
|
|
4234
|
-
(e) =>
|
|
4235
|
-
(e.descriptor.address as { channelId?: string } | undefined)?.channelId === rawChannelId &&
|
|
4236
|
-
(!serverId || e.serverId === serverId),
|
|
4237
|
-
);
|
|
4238
|
-
}
|
|
4860
|
+
const matches = this.sendTargets(rawChannelId, serverId);
|
|
4239
4861
|
if (matches.length === 0) return { status: 'unknown-channel' };
|
|
4240
4862
|
if (matches.length > 1) return { status: 'ambiguous' };
|
|
4241
4863
|
let entry = matches[0]!;
|
|
@@ -4246,8 +4868,13 @@ export class ChannelRegistry {
|
|
|
4246
4868
|
}
|
|
4247
4869
|
const desired = this.getDesiredState(entry.serverId, entry.descriptor.id);
|
|
4248
4870
|
if (entry.open && (desired === 'open' || desired === 'tuned-out')) {
|
|
4249
|
-
return { status: 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
|
|
4871
|
+
return { status: 'already-open', channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label };
|
|
4250
4872
|
}
|
|
4873
|
+
// A channel speech leaves as it stands opens nothing. Every other one
|
|
4874
|
+
// is opened, as it always was: one open at the transport with no
|
|
4875
|
+
// decision on record too, so the send's engagement is recorded as an
|
|
4876
|
+
// open decision, which a retarget or a restart carries out.
|
|
4877
|
+
if (this.speechLeavesAsItStands(entry)) return this.sendLeavesChannel(entry);
|
|
4251
4878
|
opening = desired !== 'open' && desired !== 'tuned-out';
|
|
4252
4879
|
const { opened } = await this.openChannelNow(entry, 'opened-by-reply');
|
|
4253
4880
|
entry = await this.waitForOpenChannel(entry);
|
|
@@ -4258,11 +4885,21 @@ export class ChannelRegistry {
|
|
|
4258
4885
|
channelId: entry.descriptor.id,
|
|
4259
4886
|
});
|
|
4260
4887
|
}
|
|
4261
|
-
return {
|
|
4888
|
+
return {
|
|
4889
|
+
status: opened ? 'opened' : 'already-open',
|
|
4890
|
+
channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label,
|
|
4891
|
+
};
|
|
4262
4892
|
} catch (err) {
|
|
4893
|
+
// A channel the send now leaves as it stands, such as one the agent
|
|
4894
|
+
// closed or declined while queued work or the open was out, answers as
|
|
4895
|
+
// one it found so: nothing failed that the send would have done.
|
|
4896
|
+
// Convergence that never established its target is still reported
|
|
4897
|
+
// (refusalGivesWay).
|
|
4898
|
+
const now = this.entryAt({ serverId: entry.serverId, channelId: entry.descriptor.id });
|
|
4899
|
+
if (this.refusalGivesWay(err, now)) return this.sendLeavesChannel(now);
|
|
4263
4900
|
// Where a decision made during the open's round-trip replaced it, such
|
|
4264
|
-
// as the agent's
|
|
4265
|
-
//
|
|
4901
|
+
// as the agent's channel_open, the channel is as that decision left it,
|
|
4902
|
+
// and the agent isn't told the open failed.
|
|
4266
4903
|
if (err instanceof ChannelLifecycleError && err.kind === 'overtaken') opening = false;
|
|
4267
4904
|
this.emitTraceFn({
|
|
4268
4905
|
type: 'mcpl:channel-open-failed',
|
|
@@ -4270,8 +4907,8 @@ export class ChannelRegistry {
|
|
|
4270
4907
|
channelId: entry.descriptor.id,
|
|
4271
4908
|
error: (err as Error).message,
|
|
4272
4909
|
});
|
|
4273
|
-
const current = this.
|
|
4274
|
-
return { status: 'open-failed', channelId: entry.descriptor.id, opening,
|
|
4910
|
+
const current = this.entryAt({ serverId: entry.serverId, channelId: entry.descriptor.id });
|
|
4911
|
+
return { status: 'open-failed', channelId: entry.descriptor.id, serverId: entry.serverId, opening,
|
|
4275
4912
|
// Where the channel is selected for opening after the failure, the
|
|
4276
4913
|
// agent is told so and why, as speech's failure says.
|
|
4277
4914
|
...(err instanceof ChannelLifecycleError && err.kind === 'put-back-unrecorded' ? { selectedForOpening: 'unrecorded' as const } : {}),
|
|
@@ -4280,13 +4917,22 @@ export class ChannelRegistry {
|
|
|
4280
4917
|
}
|
|
4281
4918
|
}
|
|
4282
4919
|
|
|
4920
|
+
/** What a send answers for a channel it leaves as it is
|
|
4921
|
+
* (speechLeavesAsItStands):
|
|
4922
|
+
* `already-open` where its transport is open with no close standing,
|
|
4923
|
+
* `stays-closed` otherwise. */
|
|
4924
|
+
private sendLeavesChannel(entry: ChannelEntry): { status: 'already-open' | 'stays-closed'; channelId: string; serverId: string; label?: string } {
|
|
4925
|
+
const status = entry.open && !this.closeStands(entry) ? 'already-open' : 'stays-closed';
|
|
4926
|
+
return { status, channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label };
|
|
4927
|
+
}
|
|
4928
|
+
|
|
4283
4929
|
private async handleToolOpen(input: {
|
|
4284
4930
|
channelId: string;
|
|
4285
4931
|
serverId?: string;
|
|
4286
4932
|
backscroll?: number;
|
|
4287
4933
|
beforeMessageId?: string;
|
|
4288
|
-
}): Promise<ToolResult> {
|
|
4289
|
-
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
|
|
4934
|
+
}, own?: readonly ChannelRef[]): Promise<ToolResult> {
|
|
4935
|
+
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, own);
|
|
4290
4936
|
const entry = resolved.entry;
|
|
4291
4937
|
if (!entry) {
|
|
4292
4938
|
return {
|
|
@@ -4295,13 +4941,18 @@ export class ChannelRegistry {
|
|
|
4295
4941
|
isError: true,
|
|
4296
4942
|
};
|
|
4297
4943
|
}
|
|
4944
|
+
// From here, the channel's own id, whichever name the call gave for it
|
|
4945
|
+
// (a server-qualified one included).
|
|
4946
|
+
input = { ...input, channelId: entry.descriptor.id };
|
|
4947
|
+
// The channel as the agent is shown it, and names it back (nameShown).
|
|
4948
|
+
const shown = this.nameShown(refOf(entry));
|
|
4298
4949
|
|
|
4299
4950
|
// A tuned-out channel is transport-open but attention-diverted; a plain
|
|
4300
4951
|
// open would silently discard the epoch (and its pending backlog dump).
|
|
4301
4952
|
// Cancelling is the tune-out coordinator's flow, reached via its own
|
|
4302
4953
|
// tool — refuse with the pointer rather than eat the state.
|
|
4303
4954
|
if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
|
|
4304
|
-
return tunedOutRefusal('channel_open',
|
|
4955
|
+
return this.tunedOutRefusal('channel_open', entry);
|
|
4305
4956
|
}
|
|
4306
4957
|
|
|
4307
4958
|
const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open';
|
|
@@ -4315,13 +4966,13 @@ export class ChannelRegistry {
|
|
|
4315
4966
|
} catch (err) {
|
|
4316
4967
|
return {
|
|
4317
4968
|
success: false,
|
|
4318
|
-
error: `Channel ${
|
|
4969
|
+
error: `Channel ${shown} is already open, but recording your decision to open it failed: ${(err as Error).message}`,
|
|
4319
4970
|
isError: true,
|
|
4320
4971
|
};
|
|
4321
4972
|
}
|
|
4322
4973
|
return {
|
|
4323
4974
|
success: true,
|
|
4324
|
-
data: { channelId: input.channelId, status: 'already open' },
|
|
4975
|
+
data: { channelId: input.channelId, serverId: entry.serverId, status: 'already open' },
|
|
4325
4976
|
};
|
|
4326
4977
|
}
|
|
4327
4978
|
|
|
@@ -4337,6 +4988,7 @@ export class ChannelRegistry {
|
|
|
4337
4988
|
success: true,
|
|
4338
4989
|
data: {
|
|
4339
4990
|
channelId: input.channelId,
|
|
4991
|
+
serverId: entry.serverId,
|
|
4340
4992
|
status: alreadyDesiredOpen ? 'reconciled' : 'opened',
|
|
4341
4993
|
...(result.history ? { history: result.history } : {}),
|
|
4342
4994
|
...(result.historyTruncated ? { historyTruncated: true } : {}),
|
|
@@ -4364,7 +5016,8 @@ export class ChannelRegistry {
|
|
|
4364
5016
|
* Honored ONLY for module-origin dispatch — tool input is untrusted,
|
|
4365
5017
|
* so a model-origin call carrying this field is still recorded as
|
|
4366
5018
|
* 'agent-tool' (the resident may close their own channel; they may
|
|
4367
|
-
* not attribute the act to housekeeping).
|
|
5019
|
+
* not attribute the act to housekeeping). A module-origin call
|
|
5020
|
+
* without it is the module's own close, MODULE_CLOSE_SOURCE. */
|
|
4368
5021
|
source?: string;
|
|
4369
5022
|
/** A machine close aimed at an explicitly-opened channel is refused
|
|
4370
5023
|
* unless the caller certifies an explicit idle lease (a configured
|
|
@@ -4375,7 +5028,7 @@ export class ChannelRegistry {
|
|
|
4375
5028
|
},
|
|
4376
5029
|
origin?: ChannelToolOrigin,
|
|
4377
5030
|
): Promise<ToolResult> {
|
|
4378
|
-
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
|
|
5031
|
+
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, origin?.kind === 'agent' ? origin.own : undefined);
|
|
4379
5032
|
const entry = resolved.entry;
|
|
4380
5033
|
if (!entry) {
|
|
4381
5034
|
return {
|
|
@@ -4384,14 +5037,20 @@ export class ChannelRegistry {
|
|
|
4384
5037
|
isError: true,
|
|
4385
5038
|
};
|
|
4386
5039
|
}
|
|
5040
|
+
// From here, the channel's own id, whichever name the call gave for it
|
|
5041
|
+
// (a server-qualified one included).
|
|
5042
|
+
input = { ...input, channelId: entry.descriptor.id };
|
|
5043
|
+
// The channel as the agent is shown it, and names it back (nameShown).
|
|
5044
|
+
const shown = this.nameShown(refOf(entry));
|
|
4387
5045
|
|
|
4388
5046
|
// Provenance comes from trusted dispatch, not from self-description:
|
|
4389
5047
|
// only module-origin calls may record a machine source, and only from
|
|
4390
|
-
// the closed vocabulary
|
|
5048
|
+
// the closed vocabulary; without one, a module's close is its own
|
|
5049
|
+
// (MODULE_CLOSE_SOURCE). Agent-origin (or origin-less — the safe
|
|
4391
5050
|
// default for any legacy caller) records 'agent-tool' and has its
|
|
4392
5051
|
// machine fields ignored entirely.
|
|
4393
5052
|
const isModuleOrigin = origin?.kind === 'module';
|
|
4394
|
-
let closeSource = 'agent-tool';
|
|
5053
|
+
let closeSource = isModuleOrigin ? MODULE_CLOSE_SOURCE : 'agent-tool';
|
|
4395
5054
|
if (isModuleOrigin && input.source !== undefined) {
|
|
4396
5055
|
if (!MACHINE_CLOSE_SOURCES.has(input.source)) {
|
|
4397
5056
|
return {
|
|
@@ -4416,13 +5075,13 @@ export class ChannelRegistry {
|
|
|
4416
5075
|
} catch (err) {
|
|
4417
5076
|
return {
|
|
4418
5077
|
success: false,
|
|
4419
|
-
error: `Channel ${
|
|
5078
|
+
error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${(err as Error).message}`,
|
|
4420
5079
|
isError: true,
|
|
4421
5080
|
};
|
|
4422
5081
|
}
|
|
4423
5082
|
return {
|
|
4424
5083
|
success: true,
|
|
4425
|
-
data: { channelId: input.channelId, status: 'already closed' },
|
|
5084
|
+
data: { channelId: input.channelId, serverId: entry.serverId, status: 'already closed' },
|
|
4426
5085
|
};
|
|
4427
5086
|
}
|
|
4428
5087
|
|
|
@@ -4431,7 +5090,7 @@ export class ChannelRegistry {
|
|
|
4431
5090
|
// close of a channel whose current desired state is an agent/operator
|
|
4432
5091
|
// 'open' is refused — structurally, so the caller can stand down rather
|
|
4433
5092
|
// than retry — unless it certifies an explicit idle lease.
|
|
4434
|
-
if (isModuleOrigin && closeSource
|
|
5093
|
+
if (isModuleOrigin && MACHINE_CLOSE_SOURCES.has(closeSource) && !input.overrideExplicitOpen) {
|
|
4435
5094
|
const current = this.desiredStates.get(this.lifecycleKey(entry.serverId, input.channelId));
|
|
4436
5095
|
// Tuned-out is stated intent too: a GC/housekeeping close would
|
|
4437
5096
|
// silently end the epoch and orphan its backlog.
|
|
@@ -4440,10 +5099,10 @@ export class ChannelRegistry {
|
|
|
4440
5099
|
success: false,
|
|
4441
5100
|
isError: false,
|
|
4442
5101
|
error:
|
|
4443
|
-
`Channel ${
|
|
5102
|
+
`Channel ${shown} was explicitly opened (source: agent-tool); ` +
|
|
4444
5103
|
`a '${closeSource}' close does not override stated intent. Machine closes ` +
|
|
4445
5104
|
`of explicit opens require an explicit idle lease (overrideExplicitOpen).`,
|
|
4446
|
-
data: { refusal: 'explicit-open', channelId: input.channelId },
|
|
5105
|
+
data: { refusal: 'explicit-open', channelId: input.channelId, serverId: entry.serverId },
|
|
4447
5106
|
};
|
|
4448
5107
|
}
|
|
4449
5108
|
}
|
|
@@ -4456,7 +5115,7 @@ export class ChannelRegistry {
|
|
|
4456
5115
|
// that certified an idle lease, which the guard above lets through, is
|
|
4457
5116
|
// refused too, as a refusal it stands down on rather than an error.
|
|
4458
5117
|
if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
|
|
4459
|
-
const refusal = tunedOutRefusal('channel_close',
|
|
5118
|
+
const refusal = this.tunedOutRefusal('channel_close', entry);
|
|
4460
5119
|
return isModuleOrigin ? { ...refusal, isError: false } : refusal;
|
|
4461
5120
|
}
|
|
4462
5121
|
|
|
@@ -4466,7 +5125,9 @@ export class ChannelRegistry {
|
|
|
4466
5125
|
} catch (err) {
|
|
4467
5126
|
return { success: false, error: this.lifecycleFailureText('close', entry, unrecordedDecision(err)), isError: true };
|
|
4468
5127
|
}
|
|
4469
|
-
|
|
5128
|
+
// This channel's typing, not every channel with its id (stopTyping's
|
|
5129
|
+
// fan-out of a bare name).
|
|
5130
|
+
this.stopTyping(refOf(entry));
|
|
4470
5131
|
|
|
4471
5132
|
// Refused now, the close stays recorded for the server's next
|
|
4472
5133
|
// registration, and the text says so.
|
|
@@ -4480,7 +5141,7 @@ export class ChannelRegistry {
|
|
|
4480
5141
|
if (applied.open || now !== 'closed') throw new ChannelLifecycleError(supersededClause('close', now), 'superseded');
|
|
4481
5142
|
return {
|
|
4482
5143
|
success: true,
|
|
4483
|
-
data: { channelId: input.channelId, status: 'closed' },
|
|
5144
|
+
data: { channelId: input.channelId, serverId: entry.serverId, status: 'closed' },
|
|
4484
5145
|
};
|
|
4485
5146
|
} catch (err) {
|
|
4486
5147
|
return { success: false, error: this.lifecycleFailureText('close', entry, err), isError: true };
|
|
@@ -4492,20 +5153,30 @@ export class ChannelRegistry {
|
|
|
4492
5153
|
serverId?: string;
|
|
4493
5154
|
messageId: string;
|
|
4494
5155
|
acknowledge?: string;
|
|
4495
|
-
}): Promise<ToolResult> {
|
|
4496
|
-
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
|
|
5156
|
+
}, own?: readonly ChannelRef[]): Promise<ToolResult> {
|
|
5157
|
+
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, own);
|
|
4497
5158
|
const entry = resolved.entry;
|
|
4498
5159
|
if (!entry) {
|
|
4499
5160
|
return { success: false, error: resolved.error, isError: true };
|
|
4500
5161
|
}
|
|
5162
|
+
// From here, the channel's own id, whichever name the call gave for it
|
|
5163
|
+
// (a server-qualified one included).
|
|
5164
|
+
input = { ...input, channelId: entry.descriptor.id };
|
|
5165
|
+
// The channel as the agent is shown it, and names it back (quotedShown).
|
|
5166
|
+
const quoted = this.quotedShown(refOf(entry));
|
|
4501
5167
|
// A tuned-out channel's decision is the tune-out, whatever its transport,
|
|
4502
5168
|
// and a decline recorded over it would end it without its cancel, so the
|
|
4503
5169
|
// backlog it diverted would never reach the agent. Its cancel is what ends
|
|
4504
5170
|
// it, as channel_open's refusal of a tuned-out channel says.
|
|
4505
5171
|
if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
|
|
4506
|
-
return tunedOutRefusal('channel_decline',
|
|
5172
|
+
return this.tunedOutRefusal('channel_decline', entry);
|
|
4507
5173
|
}
|
|
4508
|
-
|
|
5174
|
+
// Refused only for a channel open at the transport. One decided open
|
|
5175
|
+
// whose open failed or is still on its way isn't open there, and the
|
|
5176
|
+
// invitation a message from it brings offers the decline as the way to
|
|
5177
|
+
// reply without opening it. The decline closes it below, through the
|
|
5178
|
+
// lifecycle queue, wherever its transport may be open.
|
|
5179
|
+
if (entry.open) {
|
|
4509
5180
|
// channel_close is the call that closes it: its own answer says when,
|
|
4510
5181
|
// as for a server that isn't up. A server that's up without that grant
|
|
4511
5182
|
// keeps its channels as it chooses, so the text says so, and whom to
|
|
@@ -4514,7 +5185,7 @@ export class ChannelRegistry {
|
|
|
4514
5185
|
? this.lifecycleRefusal(entry.serverId) : undefined;
|
|
4515
5186
|
return {
|
|
4516
5187
|
success: false,
|
|
4517
|
-
error: `Channel
|
|
5188
|
+
error: `Channel ${quoted} is open, so there's nothing to decline` +
|
|
4518
5189
|
(refusal ? `, and ${refusal.message}; if you need it closed, ask whoever runs this host.`
|
|
4519
5190
|
: '. If you need it closed, call channel_close.'),
|
|
4520
5191
|
isError: true,
|
|
@@ -4529,7 +5200,7 @@ export class ChannelRegistry {
|
|
|
4529
5200
|
} catch (err) {
|
|
4530
5201
|
return {
|
|
4531
5202
|
success: false,
|
|
4532
|
-
error: `Nothing changed for channel
|
|
5203
|
+
error: `Nothing changed for channel ${quoted}: ${unrecordedDecision(err).message}, so no acknowledgment ` +
|
|
4533
5204
|
'was posted and nothing was sent to the server. Calling channel_decline again tries again; if it fails the same ' +
|
|
4534
5205
|
'way, ask whoever runs this host.',
|
|
4535
5206
|
isError: true,
|
|
@@ -4607,7 +5278,7 @@ export class ChannelRegistry {
|
|
|
4607
5278
|
// below; the decision stands.
|
|
4608
5279
|
let closeFailure: unknown;
|
|
4609
5280
|
let closeFailed = false;
|
|
4610
|
-
const current = this.
|
|
5281
|
+
const current = this.entryAt({ serverId: entry.serverId, channelId: input.channelId });
|
|
4611
5282
|
if (current && this.directsLifecycle(entry.serverId) &&
|
|
4612
5283
|
this.getDesiredState(entry.serverId, input.channelId) === 'closed' &&
|
|
4613
5284
|
(current.open || this.unconfirmedChannelTargets.has(current) || this.hasPendingLifecycle(current))) {
|
|
@@ -4631,7 +5302,7 @@ export class ChannelRegistry {
|
|
|
4631
5302
|
// asks it to open or close none of them, so a channel there is confirmed
|
|
4632
5303
|
// closed only by the receipt of a request sent while the server had the
|
|
4633
5304
|
// grant, whether the channel was registered or known only from a push.
|
|
4634
|
-
const after = this.
|
|
5305
|
+
const after = this.entryAt({ serverId: entry.serverId, channelId: input.channelId });
|
|
4635
5306
|
const closed = after !== undefined && !after.open && !this.unconfirmedChannelTargets.has(after) &&
|
|
4636
5307
|
!this.lifecycleOutstanding.has(this.lifecycleKey(entry.serverId, input.channelId));
|
|
4637
5308
|
// `closeError` is the result's sentence for the agent: what became of
|
|
@@ -4745,6 +5416,7 @@ export class ChannelRegistry {
|
|
|
4745
5416
|
success: true,
|
|
4746
5417
|
data: {
|
|
4747
5418
|
channelId: input.channelId,
|
|
5419
|
+
serverId: entry.serverId,
|
|
4748
5420
|
status,
|
|
4749
5421
|
acknowledged,
|
|
4750
5422
|
...(representation ? { representation } : {}),
|
|
@@ -4852,126 +5524,94 @@ export class ChannelRegistry {
|
|
|
4852
5524
|
* inbound-only (#350's rule), not even for a publish delivered there just
|
|
4853
5525
|
* before the declaration. One that started there before it goes on, since
|
|
4854
5526
|
* the declaration doesn't unregister the channel: a later chunk is for a
|
|
4855
|
-
* publish delivered before it.
|
|
4856
|
-
* the one server that registers it, at each call (streamingServerFor), and
|
|
4857
|
-
* keeps no record: a stream sent by destination ends by destination, since
|
|
4858
|
-
* only a destination's `complete` removes its record.
|
|
5527
|
+
* publish delivered before it.
|
|
4859
5528
|
*/
|
|
4860
5529
|
sendOutgoingChunk(
|
|
4861
|
-
to:
|
|
5530
|
+
to: PublishDestination,
|
|
4862
5531
|
conversationId: string,
|
|
4863
5532
|
inferenceId: string,
|
|
4864
5533
|
index: number,
|
|
4865
5534
|
delta: string,
|
|
4866
5535
|
): void {
|
|
4867
|
-
|
|
4868
|
-
|
|
4869
|
-
|
|
4870
|
-
|
|
4871
|
-
const key = destinationKey(to);
|
|
4872
|
-
let started = this.takenStreams.get(inferenceId);
|
|
4873
|
-
const continuing = started?.has(key) ?? false;
|
|
4874
|
-
server = this.streamingServerAt(to, !continuing);
|
|
4875
|
-
if (server && !continuing) {
|
|
4876
|
-
if (!started) this.takenStreams.set(inferenceId, started = new Set());
|
|
4877
|
-
started.add(key);
|
|
4878
|
-
}
|
|
4879
|
-
}
|
|
5536
|
+
const key = destinationKey(to);
|
|
5537
|
+
let started = this.takenStreams.get(inferenceId);
|
|
5538
|
+
const continuing = started?.has(key) ?? false;
|
|
5539
|
+
const server = this.streamingServerAt(to, !continuing);
|
|
4880
5540
|
if (!server) return;
|
|
4881
|
-
|
|
5541
|
+
if (!continuing) {
|
|
5542
|
+
if (!started) this.takenStreams.set(inferenceId, started = new Set());
|
|
5543
|
+
started.add(key);
|
|
5544
|
+
}
|
|
4882
5545
|
try {
|
|
4883
|
-
server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId, index, delta });
|
|
5546
|
+
server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId: to.channelId, index, delta });
|
|
4884
5547
|
} catch { /* observer surface — never disturb the turn */ }
|
|
4885
5548
|
}
|
|
4886
5549
|
|
|
4887
5550
|
/**
|
|
4888
5551
|
* Spec 14.3 companion: final moderated content per channel at stream end.
|
|
4889
|
-
*
|
|
4890
|
-
*
|
|
4891
|
-
*
|
|
4892
|
-
*
|
|
4893
|
-
* and reconciles any chunk it missed. A bare channel id is resolved as
|
|
4894
|
-
* before (streamingServerFor).
|
|
5552
|
+
* It goes only where the stream started (sendOutgoingChunk), while that
|
|
5553
|
+
* server still registers that channel, inbound-only or not, and its grant
|
|
5554
|
+
* has `channels.streaming`: once per destination, whose consumer, such as
|
|
5555
|
+
* a TTS utterance, finalizes on it and reconciles any chunk it missed.
|
|
4895
5556
|
*/
|
|
4896
5557
|
sendOutgoingComplete(
|
|
4897
|
-
to:
|
|
5558
|
+
to: PublishDestination,
|
|
4898
5559
|
conversationId: string,
|
|
4899
5560
|
inferenceId: string,
|
|
4900
5561
|
text: string,
|
|
4901
5562
|
): void {
|
|
4902
|
-
|
|
4903
|
-
if (
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
const started = this.takenStreams.get(inferenceId);
|
|
4907
|
-
if (!started?.delete(destinationKey(to))) return;
|
|
4908
|
-
if (started.size === 0) this.takenStreams.delete(inferenceId);
|
|
4909
|
-
server = this.streamingServerAt(to, false);
|
|
4910
|
-
}
|
|
5563
|
+
const started = this.takenStreams.get(inferenceId);
|
|
5564
|
+
if (!started?.delete(destinationKey(to))) return;
|
|
5565
|
+
if (started.size === 0) this.takenStreams.delete(inferenceId);
|
|
5566
|
+
const server = this.streamingServerAt(to, false);
|
|
4911
5567
|
if (!server) return;
|
|
4912
5568
|
try {
|
|
4913
5569
|
server.sendChannelsOutgoingComplete({
|
|
4914
5570
|
inferenceId,
|
|
4915
5571
|
conversationId,
|
|
4916
|
-
channelId:
|
|
5572
|
+
channelId: to.channelId,
|
|
4917
5573
|
content: [{ type: 'text', text }],
|
|
4918
5574
|
});
|
|
4919
5575
|
} catch { /* observer surface — never disturb the turn */ }
|
|
4920
5576
|
}
|
|
4921
5577
|
|
|
4922
|
-
private streamingServerFor(channelId: string) {
|
|
4923
|
-
// The map is keyed `${serverId}:${channelId}`; stream targets arrive as
|
|
4924
|
-
// bare descriptor ids (what resolveProseTarget returns). Scan like the
|
|
4925
|
-
// resolver does, and fail closed on cross-server ambiguity — the same
|
|
4926
|
-
// never-guess rule that governs delivery.
|
|
4927
|
-
const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
|
|
4928
|
-
if (matches.length !== 1) return null;
|
|
4929
|
-
// Nothing is published into an inbound-only channel, so nothing is
|
|
4930
|
-
// streamed there either.
|
|
4931
|
-
if (isInboundOnly(matches[0]!)) return null;
|
|
4932
|
-
const server = this.serverRegistry.getServer(matches[0]!.serverId);
|
|
4933
|
-
// §5.4: the GRANT gates streaming, not the raw advertisement. The old
|
|
4934
|
-
// `capabilities?.channels?.streaming` check was doubly wrong: undefined
|
|
4935
|
-
// for the boolean `channels: true` shape (masking discord-mcpl's latent
|
|
4936
|
-
// double-post, AUDIT-001), and pre-policy it would have sent before the
|
|
4937
|
-
// server was told anything was granted.
|
|
4938
|
-
if (!CapabilityGrant.of(server).has('channels.streaming')) return null;
|
|
4939
|
-
return server;
|
|
4940
|
-
}
|
|
4941
|
-
|
|
4942
5578
|
/**
|
|
4943
5579
|
* A destination's server, for a stream message: while that server
|
|
4944
|
-
* registers that channel, and its grant has `channels.streaming` (
|
|
4945
|
-
*
|
|
5580
|
+
* registers that channel, and its grant has `channels.streaming` (§5.4:
|
|
5581
|
+
* the grant gates streaming, not the raw advertisement). A stream doesn't
|
|
5582
|
+
* start in an inbound-only channel.
|
|
4946
5583
|
*/
|
|
4947
|
-
private streamingServerAt(to:
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
const entry = this.channels.get(`${to.serverId}:${to.channelId}`);
|
|
4951
|
-
if (!entry || entry.serverId !== to.serverId) return null;
|
|
5584
|
+
private streamingServerAt(to: ChannelRef, starting: boolean): McplServerConnection | null {
|
|
5585
|
+
const entry = this.entryAt(to);
|
|
5586
|
+
if (!entry) return null;
|
|
4952
5587
|
if (starting && isInboundOnly(entry)) return null;
|
|
4953
5588
|
const server = this.serverRegistry.getServer(to.serverId);
|
|
4954
5589
|
return CapabilityGrant.of(server).has('channels.streaming') ? server : null;
|
|
4955
5590
|
}
|
|
4956
5591
|
|
|
4957
5592
|
/** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel →
|
|
4958
|
-
* process-global default). Public so a multi-segment
|
|
4959
|
-
* ONCE and pin every segment to it via
|
|
4960
|
-
|
|
5593
|
+
* process-global default), with its server. Public so a multi-segment
|
|
5594
|
+
* caller can snapshot it ONCE and pin every segment to it via
|
|
5595
|
+
* routeSpeech's locus. */
|
|
5596
|
+
resolveLocus(conversationId: string): ChannelRef | null {
|
|
4961
5597
|
const home = this.homeChannelResolver?.(conversationId);
|
|
4962
|
-
|
|
5598
|
+
if (home) return home;
|
|
5599
|
+
// A turn that deliberately has no channel (null) takes no fallback.
|
|
5600
|
+
const active = this.activeChannelResolver?.(conversationId);
|
|
5601
|
+
return active !== undefined ? active : this.globalFallbackFor(conversationId);
|
|
4963
5602
|
}
|
|
4964
5603
|
|
|
4965
5604
|
/** The process-global fallback channel for this conversation, or null when it takes none (takesGlobalFallback). */
|
|
4966
|
-
private globalFallbackFor(conversationId: string):
|
|
5605
|
+
private globalFallbackFor(conversationId: string): ChannelRef | null {
|
|
4967
5606
|
return this.takesGlobalFallback?.(conversationId) === false ? null : this.defaultPublishChannel;
|
|
4968
5607
|
}
|
|
4969
5608
|
|
|
4970
5609
|
/**
|
|
4971
5610
|
* Resolve a publish target to exactly one registered channel: `serverId`
|
|
4972
|
-
* plus `channelId` when the caller has the server, otherwise
|
|
4973
|
-
*
|
|
4974
|
-
* to whichever server registered it
|
|
5611
|
+
* plus `channelId` when the caller has the server, otherwise the one
|
|
5612
|
+
* channel the id, or its server-qualified name, means (entriesNamed). A
|
|
5613
|
+
* shared id is refused, never resolved to whichever server registered it
|
|
5614
|
+
* first.
|
|
4975
5615
|
*/
|
|
4976
5616
|
resolveDestination(
|
|
4977
5617
|
target: { serverId?: string; channelId: string },
|
|
@@ -4998,9 +5638,11 @@ export class ChannelRegistry {
|
|
|
4998
5638
|
if (target.serverId !== undefined && (typeof target.serverId !== 'string' || !target.serverId)) {
|
|
4999
5639
|
return { error: 'serverId must name a server (omit it to resolve the channel id alone)' };
|
|
5000
5640
|
}
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5641
|
+
// Without a server, the channel id, or its server-qualified name, means
|
|
5642
|
+
// what it means to every channel tool (entriesNamed).
|
|
5643
|
+
const matches = target.serverId
|
|
5644
|
+
? [this.entryAt({ serverId: target.serverId, channelId: target.channelId })].filter((e): e is ChannelEntry => e !== undefined)
|
|
5645
|
+
: this.entriesNamed(target.channelId);
|
|
5004
5646
|
if (matches.length === 0) {
|
|
5005
5647
|
return {
|
|
5006
5648
|
error: target.serverId
|
|
@@ -5009,12 +5651,16 @@ export class ChannelRegistry {
|
|
|
5009
5651
|
};
|
|
5010
5652
|
}
|
|
5011
5653
|
if (matches.length > 1) {
|
|
5654
|
+
// The framework's own speech always names its server, so only a
|
|
5655
|
+
// caller that named none meets this, and it can name one.
|
|
5656
|
+
// Each named by what picks it alone, or by its id and server where
|
|
5657
|
+
// nothing does; "more than one server" only where the servers differ.
|
|
5658
|
+
const names = matches.map((e) => this.quotedShown(refOf(e)));
|
|
5659
|
+
const servers = new Set(matches.map((e) => e.serverId));
|
|
5012
5660
|
return {
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
error: `channel id "${target.channelId}" is on more than one server, and no server was named for these words; ` +
|
|
5017
|
-
'if you need them there, ask whoever runs this host',
|
|
5661
|
+
error: servers.size > 1
|
|
5662
|
+
? `channel id "${target.channelId}" is on more than one server (${names.join(', ')}), and the destination named no server`
|
|
5663
|
+
: `channel id "${target.channelId}" names more than one channel on server "${matches[0]!.serverId}" (${names.join(', ')})`,
|
|
5018
5664
|
};
|
|
5019
5665
|
}
|
|
5020
5666
|
return { entry: matches[0]! };
|
|
@@ -5029,16 +5675,19 @@ export class ChannelRegistry {
|
|
|
5029
5675
|
* publishReceiptOutcome and publishErrorOutcome.
|
|
5030
5676
|
*
|
|
5031
5677
|
* Resolution is exact (resolveDestination): `serverId` plus `channelId`
|
|
5032
|
-
* when the caller has the server, otherwise the channel id
|
|
5033
|
-
*
|
|
5034
|
-
* through whichever server came first). An omitted `serverId` means "not
|
|
5678
|
+
* when the caller has the server, otherwise the channel id, or its
|
|
5679
|
+
* server-qualified name, must mean exactly one channel (a shared id is
|
|
5680
|
+
* refused, never routed through whichever server came first). An omitted `serverId` means "not
|
|
5035
5681
|
* in use"; a supplied one must be a non-empty server id, and an empty or
|
|
5036
5682
|
* non-string selector is refused rather than read as omission. It is not
|
|
5037
|
-
* possible to send into a closed channel
|
|
5038
|
-
*
|
|
5039
|
-
*
|
|
5040
|
-
*
|
|
5041
|
-
*
|
|
5683
|
+
* possible to send into a closed channel that speech opens (speechOpens):
|
|
5684
|
+
* it's opened first (`openSource` names why), once its server and that
|
|
5685
|
+
* server's channels.publish grant are known to take the send, so a send
|
|
5686
|
+
* refused before the open opens nothing; and a failed open sends nothing.
|
|
5687
|
+
* A channel speech leaves as it stands (speechLeavesAsItStands), one the
|
|
5688
|
+
* agent closed or declined itself or a module closed, or a closed one on
|
|
5689
|
+
* a server that's up but whose lifecycle this host doesn't direct, takes
|
|
5690
|
+
* the words as it is, once its queued lifecycle work has settled.
|
|
5042
5691
|
*
|
|
5043
5692
|
* A channel its server declared inbound-only (isInboundOnly) takes nothing
|
|
5044
5693
|
* the host would post, whichever path the words took, a drafts resend
|
|
@@ -5114,30 +5763,41 @@ export class ChannelRegistry {
|
|
|
5114
5763
|
status: 'failed', destination, reason: lifecycleClause(err) + refusalRemedy(err), at: at(),
|
|
5115
5764
|
});
|
|
5116
5765
|
|
|
5766
|
+
// Words go into a channel that's open with no close standing, or into
|
|
5767
|
+
// one speech leaves as it stands (speechLeavesAsItStands): a channel the
|
|
5768
|
+
// agent closed or declined itself, or a module closed, and a closed
|
|
5769
|
+
// channel on a server that's up but whose lifecycle this host doesn't
|
|
5770
|
+
// direct, where nothing opens. Every other channel speech opens first
|
|
5771
|
+
// (speechOpens), and if it doesn't open, nothing goes out. A wait or an
|
|
5772
|
+
// open that fails is read against the channel as it is then, so a close
|
|
5773
|
+
// the agent decides while the words wait lets them go into the channel
|
|
5774
|
+
// as that close leaves it, unless the failure is convergence that never
|
|
5775
|
+
// established its target (refusalGivesWay). A channel the server last
|
|
5776
|
+
// answered for another one takes nothing as it stands (answeredElsewhere).
|
|
5777
|
+
|
|
5117
5778
|
// Lifecycle work already queued for the channel settles first: words go
|
|
5118
5779
|
// out only into the channel it leaves, and fail visibly if it leaves
|
|
5119
|
-
// none open.
|
|
5780
|
+
// none open and speech would open it.
|
|
5120
5781
|
if (this.hasPendingLifecycle(entry)) {
|
|
5121
5782
|
try {
|
|
5122
5783
|
entry = await this.waitForOpenChannel(entry);
|
|
5123
5784
|
} catch (err) {
|
|
5124
|
-
|
|
5785
|
+
const now = this.entryAt(refOf(entry));
|
|
5786
|
+
if (!this.refusalGivesWay(err, now)) return prevented(err);
|
|
5787
|
+
entry = now;
|
|
5125
5788
|
}
|
|
5126
5789
|
}
|
|
5127
5790
|
|
|
5128
|
-
//
|
|
5129
|
-
//
|
|
5130
|
-
//
|
|
5131
|
-
//
|
|
5132
|
-
//
|
|
5133
|
-
//
|
|
5134
|
-
//
|
|
5135
|
-
//
|
|
5136
|
-
//
|
|
5137
|
-
|
|
5138
|
-
const engagesDecidedClosed = this.getDesiredState(entry.serverId, entry.descriptor.id) === 'closed' &&
|
|
5139
|
-
CapabilityGrant.of(server).has('channels.lifecycle');
|
|
5140
|
-
if (!entry.open || engagesDecidedClosed) {
|
|
5791
|
+
// A send into a closed-but-registered channel (the canonical case: a DM
|
|
5792
|
+
// that woke the agent, since DMs register closed) OPENS it first where
|
|
5793
|
+
// speech opens it (speechOpens), so typing indicators, reaction
|
|
5794
|
+
// machinery and inbound forwarding come alive with the words. If the
|
|
5795
|
+
// open fails, nothing goes out: a half-alive delivery is worse than a
|
|
5796
|
+
// loud marker. A channel decided closed is engaged even while its
|
|
5797
|
+
// transport is still open (a close that failed), as a reply would engage
|
|
5798
|
+
// it, wherever an open can succeed, unless that close is the agent's or
|
|
5799
|
+
// a module's own.
|
|
5800
|
+
if (this.speechOpens(refOf(entry))) {
|
|
5141
5801
|
try {
|
|
5142
5802
|
const { opened } = await this.openChannelNow(entry, openSource);
|
|
5143
5803
|
entry = await this.waitForOpenChannel(entry);
|
|
@@ -5166,7 +5826,9 @@ export class ChannelRegistry {
|
|
|
5166
5826
|
}
|
|
5167
5827
|
}
|
|
5168
5828
|
} catch (err) {
|
|
5169
|
-
|
|
5829
|
+
const now = this.entryAt(refOf(entry));
|
|
5830
|
+
if (!this.refusalGivesWay(err, now)) return { status: 'failed', destination, reason: openFailedReason(err), at: at() };
|
|
5831
|
+
entry = now;
|
|
5170
5832
|
}
|
|
5171
5833
|
}
|
|
5172
5834
|
|
|
@@ -5183,14 +5845,16 @@ export class ChannelRegistry {
|
|
|
5183
5845
|
// its close standing. The loop opens nothing itself. The check then runs
|
|
5184
5846
|
// again on the registration that stands. waitForOpenChannel returns
|
|
5185
5847
|
// only a registration it found open and whose close doesn't stand, or
|
|
5186
|
-
// throws, so the loop ends unless the channel keeps changing.
|
|
5187
|
-
|
|
5848
|
+
// throws, so the loop ends unless the channel keeps changing. A channel
|
|
5849
|
+
// speech leaves as it stands (speechLeavesAsItStands) needs none of
|
|
5850
|
+
// that, and its words go into it as it is.
|
|
5851
|
+
const key = destinationKey({ serverId: entry.serverId, channelId: entry.descriptor.id });
|
|
5188
5852
|
let current = this.channels.get(key);
|
|
5189
|
-
while (current && !isInboundOnly(current) && (!current.open || this.closeStands(current))) {
|
|
5853
|
+
while (current && !isInboundOnly(current) && (!current.open || this.closeStands(current)) && !this.speechLeavesAsItStands(current)) {
|
|
5190
5854
|
try {
|
|
5191
5855
|
entry = await this.waitForOpenChannel(current);
|
|
5192
5856
|
} catch (err) {
|
|
5193
|
-
return prevented(err);
|
|
5857
|
+
if (!this.refusalGivesWay(err, this.channels.get(key))) return prevented(err);
|
|
5194
5858
|
}
|
|
5195
5859
|
current = this.channels.get(key);
|
|
5196
5860
|
}
|
|
@@ -5198,6 +5862,17 @@ export class ChannelRegistry {
|
|
|
5198
5862
|
if (isInboundOnly(current)) {
|
|
5199
5863
|
return { status: 'failed', destination, reason: inboundOnlyReason(current), at: at() };
|
|
5200
5864
|
}
|
|
5865
|
+
// A channel the words go into as it stands, with no open to check what
|
|
5866
|
+
// the server reaches by its id, takes nothing where the server answered
|
|
5867
|
+
// its last open for another channel, whenever that was (answeredElsewhere):
|
|
5868
|
+
// the words could reach that other channel.
|
|
5869
|
+
if ((!current.open || this.closeStands(current)) && this.answeredElsewhere.has(current)) {
|
|
5870
|
+
return {
|
|
5871
|
+
status: 'failed', destination, at: at(),
|
|
5872
|
+
reason: `the "${current.serverId}" server answered this channel's last open for a different channel, so words ` +
|
|
5873
|
+
"sent by its id may not reach it; once an open of it is answered for this channel, they go into it again",
|
|
5874
|
+
};
|
|
5875
|
+
}
|
|
5201
5876
|
// A grant reduced during any of the waits holds at once (§6.7): the
|
|
5202
5877
|
// words already on their way take it too. Its refusal is worded for the
|
|
5203
5878
|
// connection that refused (sendRefusal's `checked`).
|
|
@@ -5233,7 +5908,7 @@ export class ChannelRegistry {
|
|
|
5233
5908
|
async deliverSpeech(
|
|
5234
5909
|
conversationId: string,
|
|
5235
5910
|
text: string,
|
|
5236
|
-
/** The turn's FROZEN locus, snapshotted once by the caller (resolveLocus
|
|
5911
|
+
/** The turn's FROZEN locus, with its server, snapshotted once by the caller (resolveLocus
|
|
5237
5912
|
* at turn start) and passed to every segment of the turn. Mandatory:
|
|
5238
5913
|
* there is deliberately no live-resolution fallback here — a late
|
|
5239
5914
|
* dispatch resolving live can read the NEXT turn's trigger state or a
|
|
@@ -5242,7 +5917,7 @@ export class ChannelRegistry {
|
|
|
5242
5917
|
* "this turn is pinned to no locus": fail loudly rather than guess,
|
|
5243
5918
|
* except for an ephemeral run, whose speech is then its caller's
|
|
5244
5919
|
* result. */
|
|
5245
|
-
|
|
5920
|
+
locus: ChannelRef | null,
|
|
5246
5921
|
/** `heldOnFailure`: the caller holds a `failed` delivery's words as a
|
|
5247
5922
|
* draft with its own notice, so no failure marker is written for it.
|
|
5248
5923
|
* An `unknown` outcome still gets its marker. */
|
|
@@ -5250,14 +5925,16 @@ export class ChannelRegistry {
|
|
|
5250
5925
|
): Promise<PublishOutcome> {
|
|
5251
5926
|
// `logged` is what the log line and the trace say, where it isn't the
|
|
5252
5927
|
// agent's reason (the two no-locus reasons below).
|
|
5253
|
-
const fail = (
|
|
5928
|
+
const fail = (channel: ChannelRef | null, reason: string, outcome: PublishOutcome, logged = reason): PublishOutcome => {
|
|
5254
5929
|
const status = outcome.status === 'unknown' ? 'unknown' : 'failed';
|
|
5930
|
+
const channelId = channel?.channelId ?? null;
|
|
5255
5931
|
const detail = outcome.detail !== undefined ? connectorText(outcome.detail) : undefined;
|
|
5256
5932
|
const detailText = detail !== undefined ? `; connector detail: ${detail}` : '';
|
|
5257
5933
|
console.error(`[routeSpeech] ${conversationId}: ${logged}${detailText} — speech ${status === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`);
|
|
5258
5934
|
this.emitTraceFn({
|
|
5259
5935
|
type: 'mcpl:speech-route-failed',
|
|
5260
5936
|
conversationId,
|
|
5937
|
+
...(channel ? { serverId: channel.serverId } : {}),
|
|
5261
5938
|
channelId: channelId ?? '',
|
|
5262
5939
|
reason: logged,
|
|
5263
5940
|
textLen: text.length,
|
|
@@ -5265,7 +5942,10 @@ export class ChannelRegistry {
|
|
|
5265
5942
|
...(detail !== undefined ? { detail } : {}),
|
|
5266
5943
|
});
|
|
5267
5944
|
if (status === 'unknown' || !opts?.heldOnFailure) {
|
|
5268
|
-
this.onRouteFailure?.({
|
|
5945
|
+
this.onRouteFailure?.({
|
|
5946
|
+
conversationId, channelId, ...(channel ? { serverId: channel.serverId } : {}),
|
|
5947
|
+
reason, textLen: text.length, outcome: status, ...(detail !== undefined ? { detail } : {}),
|
|
5948
|
+
});
|
|
5269
5949
|
}
|
|
5270
5950
|
return outcome;
|
|
5271
5951
|
};
|
|
@@ -5279,12 +5959,12 @@ export class ChannelRegistry {
|
|
|
5279
5959
|
// Runtime backstop for JS callers the compiler can't see: an omitted
|
|
5280
5960
|
// locus is a routing bug at the call site, never something to paper over
|
|
5281
5961
|
// with a live re-resolution.
|
|
5282
|
-
if (
|
|
5962
|
+
if (locus === undefined) {
|
|
5283
5963
|
const reason = 'the host lost track of which channel this reply was for; if this keeps happening, ask whoever runs this host';
|
|
5284
5964
|
return fail(null, reason, { status: 'failed', reason, at: Date.now() },
|
|
5285
5965
|
'caller passed no locus (routing bug: every speech path must snapshot the turn locus)');
|
|
5286
5966
|
}
|
|
5287
|
-
if (!
|
|
5967
|
+
if (!locus) {
|
|
5288
5968
|
// An ephemeral run with no channel of its own: its plain speech is its
|
|
5289
5969
|
// caller's result (takesGlobalFallback). It returns as not delivered,
|
|
5290
5970
|
// with its own reason, and writes no line, trace or notice.
|
|
@@ -5301,10 +5981,10 @@ export class ChannelRegistry {
|
|
|
5301
5981
|
'turn has no locus (no home/trigger channel; nothing to deliver into)');
|
|
5302
5982
|
}
|
|
5303
5983
|
|
|
5304
|
-
const outcome = await this.publish(conversationId, text, { channelId:
|
|
5305
|
-
const channelId = outcome.destination?.channelId ??
|
|
5984
|
+
const outcome = await this.publish(conversationId, text, { serverId: locus.serverId, channelId: locus.channelId, routed: true });
|
|
5985
|
+
const channelId = outcome.destination?.channelId ?? locus.channelId;
|
|
5306
5986
|
if (outcome.status !== 'delivered') {
|
|
5307
|
-
return fail(
|
|
5987
|
+
return fail(outcome.destination ?? locus, outcome.reason ?? 'delivery not confirmed', outcome);
|
|
5308
5988
|
}
|
|
5309
5989
|
|
|
5310
5990
|
console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${logValue(channelId)} (server=${outcome.destination!.serverId}, delivered=true)`);
|
|
@@ -5328,9 +6008,9 @@ export class ChannelRegistry {
|
|
|
5328
6008
|
async routeSpeech(
|
|
5329
6009
|
conversationId: string,
|
|
5330
6010
|
text: string,
|
|
5331
|
-
|
|
6011
|
+
locus: ChannelRef | null,
|
|
5332
6012
|
): Promise<{ delivered: boolean; serverId: string; channelId: string; messageId?: string } | null> {
|
|
5333
|
-
const outcome = await this.deliverSpeech(conversationId, text,
|
|
6013
|
+
const outcome = await this.deliverSpeech(conversationId, text, locus);
|
|
5334
6014
|
if (outcome.status !== 'delivered') return null;
|
|
5335
6015
|
return {
|
|
5336
6016
|
delivered: true,
|
|
@@ -5347,12 +6027,15 @@ export class ChannelRegistry {
|
|
|
5347
6027
|
* given speak_in_channel for the tuned-out channels whose messages it reads.
|
|
5348
6028
|
*/
|
|
5349
6029
|
private async handleToolPublish(
|
|
5350
|
-
|
|
6030
|
+
/** `serverId` isn't in channel_publish's schema: the framework gives it
|
|
6031
|
+
* with a conversation fork's home, and publishForAgent with a ref. */
|
|
6032
|
+
input: { channelId?: string; serverId?: string; content?: string; text?: string },
|
|
5351
6033
|
origin?: ChannelToolOrigin,
|
|
5352
|
-
/** Filled for publishForAgent: the
|
|
5353
|
-
*
|
|
5354
|
-
*
|
|
5355
|
-
|
|
6034
|
+
/** Filled for publishForAgent: the channel the call names once it
|
|
6035
|
+
* resolves, and the publish's outcome once a request is attempted, for
|
|
6036
|
+
* its trace, or which check of the destination refused it before
|
|
6037
|
+
* anything went out, for its answer. */
|
|
6038
|
+
seen?: { outcome?: PublishOutcome; resolved?: ChannelRef; refusal?: PublishRefusalReason },
|
|
5356
6039
|
tool: 'channel_publish' | 'speak_in_channel' = 'channel_publish',
|
|
5357
6040
|
): Promise<ToolResult> {
|
|
5358
6041
|
const findIds = tool === 'channel_publish'
|
|
@@ -5377,9 +6060,13 @@ export class ChannelRegistry {
|
|
|
5377
6060
|
// marker, apart from an edit the coalescer delivers on acceptance as the
|
|
5378
6061
|
// agent's first view. An ephemeral run never takes that default
|
|
5379
6062
|
// (takesGlobalFallback), so it names one.
|
|
6063
|
+
// A channel named by a string means what it means to every channel tool
|
|
6064
|
+
// (entriesNamed): one channel, by its id or its server-qualified name,
|
|
6065
|
+
// and a name two servers' channels answer to is refused with them as
|
|
6066
|
+
// candidates, never sent to the first.
|
|
5380
6067
|
const caller = origin?.kind === 'agent' ? origin.agentName : undefined;
|
|
5381
|
-
const
|
|
5382
|
-
if (!channelId) {
|
|
6068
|
+
const fallback = caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel;
|
|
6069
|
+
if (!(input.channelId ?? fallback)) {
|
|
5383
6070
|
return {
|
|
5384
6071
|
success: false,
|
|
5385
6072
|
error: tool === 'speak_in_channel'
|
|
@@ -5391,17 +6078,80 @@ export class ChannelRegistry {
|
|
|
5391
6078
|
isError: true,
|
|
5392
6079
|
};
|
|
5393
6080
|
}
|
|
5394
|
-
|
|
5395
|
-
|
|
6081
|
+
// A host's ref (publishForAgent, the one caller that gives
|
|
6082
|
+
// speak_in_channel a serverId) names its exact pair, an empty serverId
|
|
6083
|
+
// included, as publish's destination does. A tool's empty serverId is a
|
|
6084
|
+
// parameter filled with nothing, read as none given, as the channel tools
|
|
6085
|
+
// read one.
|
|
6086
|
+
const serverGiven = tool === 'speak_in_channel' ? input.serverId !== undefined : !!input.serverId;
|
|
6087
|
+
// Inside the calling turn, a name one of the turn's own channels answers
|
|
6088
|
+
// to by its id means that channel, as a `>>` target does (ownChannelNamed).
|
|
6089
|
+
const own = input.channelId !== undefined && !serverGiven && origin?.kind === 'agent'
|
|
6090
|
+
? this.ownChannelNamed(input.channelId, origin.own)
|
|
6091
|
+
: undefined;
|
|
6092
|
+
let entry: ChannelEntry | undefined;
|
|
6093
|
+
if (input.channelId === undefined) {
|
|
6094
|
+
// A serverId given with no channelId names the default's id on that
|
|
6095
|
+
// server, held to as one given with a channelId is.
|
|
6096
|
+
entry = this.entryAt(serverGiven ? { serverId: input.serverId!, channelId: fallback!.channelId } : fallback!);
|
|
6097
|
+
} else if (own && 'neither' in own) {
|
|
6098
|
+
if (seen) seen.refusal = 'shared-id';
|
|
6099
|
+
return { success: false, error: this.ownNeitherRefusal(input.channelId, own.neither, 'was posted'), isError: true };
|
|
6100
|
+
} else if (own) {
|
|
6101
|
+
entry = this.entryAt(own.channel);
|
|
6102
|
+
if (!entry) {
|
|
6103
|
+
if (seen) seen.refusal = 'channel-not-registered';
|
|
6104
|
+
return { success: false, error: this.ownChannelGoneRefusal(input.channelId, own.channel, 'publish'), isError: true };
|
|
6105
|
+
}
|
|
6106
|
+
} else if (serverGiven) {
|
|
6107
|
+
entry = this.entryAt({ serverId: input.serverId!, channelId: input.channelId });
|
|
6108
|
+
} else {
|
|
6109
|
+
const matches = this.entriesNamed(input.channelId);
|
|
6110
|
+
if (matches.length > 1) {
|
|
6111
|
+
if (seen) seen.refusal = 'shared-id';
|
|
6112
|
+
return {
|
|
6113
|
+
success: false,
|
|
6114
|
+
error: this.sharedNameRefusal(input.channelId, matches, { takesServerId: false, nothing: 'nothing was posted' }),
|
|
6115
|
+
isError: true,
|
|
6116
|
+
};
|
|
6117
|
+
}
|
|
6118
|
+
entry = matches[0];
|
|
6119
|
+
}
|
|
5396
6120
|
if (!entry) {
|
|
5397
6121
|
if (seen) seen.refusal = 'channel-not-registered';
|
|
5398
|
-
return {
|
|
6122
|
+
return {
|
|
6123
|
+
success: false,
|
|
6124
|
+
// The default channel is gone (its server no longer registers it),
|
|
6125
|
+
// though another server's channel may have its id.
|
|
6126
|
+
// No advice: the default's channel is gone, and another server's
|
|
6127
|
+
// channel with its id isn't where its message came from.
|
|
6128
|
+
error: input.channelId === undefined
|
|
6129
|
+
? serverGiven
|
|
6130
|
+
? `Server "${input.serverId}" has no channel "${fallback!.channelId}", the id of the channel this call would default to, so nothing was posted.`
|
|
6131
|
+
: `The channel this would default to, ${this.quotedShown(fallback!)}, isn't registered now, so nothing was posted.`
|
|
6132
|
+
// This tool's schema has no serverId, so one given here is a host's
|
|
6133
|
+
// ref (publishForAgent), or one an agent added unasked, and the
|
|
6134
|
+
// refusal offers names its channelId takes instead. (A fork's guard
|
|
6135
|
+
// refuses a home its server no longer registers before this runs.)
|
|
6136
|
+
: serverGiven
|
|
6137
|
+
? `No channel "${input.channelId}" is on server "${input.serverId}", so nothing was posted. ${this.elsewhereText(input.channelId, false)}`
|
|
6138
|
+
: `No channel has the id "${input.channelId}", so nothing was posted. ${findIds}`,
|
|
6139
|
+
isError: true,
|
|
6140
|
+
};
|
|
5399
6141
|
}
|
|
6142
|
+
// From here, the channel's own id, whichever name the call gave for it,
|
|
6143
|
+
// and the name the agent is shown it by (nameShown).
|
|
6144
|
+
const channelId = entry.descriptor.id;
|
|
6145
|
+
// The channel the call names, for a caller that reports a refusal before
|
|
6146
|
+
// the send (publishForAgent's trace).
|
|
6147
|
+
if (seen) seen.resolved = refOf(entry);
|
|
6148
|
+
const shown = this.nameShown(refOf(entry));
|
|
6149
|
+
const quoted = this.quotedShown(refOf(entry));
|
|
5400
6150
|
if (isInboundOnly(entry)) {
|
|
5401
6151
|
if (seen) seen.refusal = 'inbound-only';
|
|
5402
6152
|
return {
|
|
5403
6153
|
success: false,
|
|
5404
|
-
error: `Channel ${
|
|
6154
|
+
error: `Channel ${shown} is ${inboundOnlyReason(entry)}`,
|
|
5405
6155
|
isError: true,
|
|
5406
6156
|
};
|
|
5407
6157
|
}
|
|
@@ -5414,7 +6164,7 @@ export class ChannelRegistry {
|
|
|
5414
6164
|
const reach = this.serverReach(entry.serverId);
|
|
5415
6165
|
seen.refusal = reach === 'up' ? 'not-permitted' : `server-${reach}`;
|
|
5416
6166
|
}
|
|
5417
|
-
return { success: false, error: `Nothing was posted to channel
|
|
6167
|
+
return { success: false, error: `Nothing was posted to channel ${quoted}: ${this.sendRefusal(entry.serverId)}.`, isError: true };
|
|
5418
6168
|
}
|
|
5419
6169
|
const publishParams: ChannelsPublishParams = {
|
|
5420
6170
|
conversationId: '', // Framework will fill this when wired
|
|
@@ -5444,11 +6194,15 @@ export class ChannelRegistry {
|
|
|
5444
6194
|
if (outcome.status === 'delivered') return { success: true, data: result };
|
|
5445
6195
|
// The connector's data may say what a multi-part send already posted.
|
|
5446
6196
|
const detail = outcome.status === 'unknown' && outcome.detail !== undefined ? ` Connector detail: ${connectorText(outcome.detail)}` : '';
|
|
6197
|
+
// Named as it's shown now, after the send's await: registrations may have
|
|
6198
|
+
// changed meanwhile, and the name shown before it may now pick another
|
|
6199
|
+
// channel.
|
|
6200
|
+
const sentTo = this.quotedShown(refOf(entry));
|
|
5447
6201
|
return {
|
|
5448
6202
|
success: false,
|
|
5449
6203
|
error: (outcome.status === 'failed'
|
|
5450
|
-
? `Nothing was posted to channel
|
|
5451
|
-
: `Posting to channel
|
|
6204
|
+
? `Nothing was posted to channel ${sentTo}: ${outcome.reason}.`
|
|
6205
|
+
: `Posting to channel ${sentTo} wasn't confirmed: ${outcome.reason}. ` +
|
|
5452
6206
|
'It may or may not have been posted, so sending it again could post it twice.') + detail,
|
|
5453
6207
|
isError: true,
|
|
5454
6208
|
};
|