@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
|
@@ -23,6 +23,7 @@ import { RefusalLines } from './refusal-lines.js';
|
|
|
23
23
|
import { CapabilityGrant } from './capability-grant.js';
|
|
24
24
|
import { McplRequestError } from './server-connection.js';
|
|
25
25
|
import { readSkipReplyWake } from './skip-reply-wake.js';
|
|
26
|
+
import { isProseTargetToken } from './prose-grammar.js';
|
|
26
27
|
// ============================================================================
|
|
27
28
|
// Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive)
|
|
28
29
|
// ============================================================================
|
|
@@ -70,6 +71,20 @@ class ChannelLifecycleError extends Error {
|
|
|
70
71
|
this.by = options?.by;
|
|
71
72
|
}
|
|
72
73
|
}
|
|
74
|
+
/** Whether a lifecycle failure, or one it wraps (an overtaken open's
|
|
75
|
+
* `cause`), is convergence that never established the channel's target.
|
|
76
|
+
* Words don't go into such a channel on the strength of a close the agent
|
|
77
|
+
* decided meanwhile (refusalGivesWay): the address they'd go to is the one
|
|
78
|
+
* that kept changing. A later operation that settles on the current target
|
|
79
|
+
* establishes it, so this is the failure's, not the channel's (compare
|
|
80
|
+
* answeredElsewhere). */
|
|
81
|
+
function convergeFailure(err) {
|
|
82
|
+
for (let e = err; e instanceof Error; e = e.cause) {
|
|
83
|
+
if (e instanceof ChannelLifecycleError && e.kind === 'converge')
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
73
88
|
const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle';
|
|
74
89
|
/** The sources of a decision the agent itself made: channel_open and
|
|
75
90
|
* channel_close, and channel_decline. */
|
|
@@ -194,6 +209,10 @@ function shallowEqualRecord(a, b) {
|
|
|
194
209
|
}
|
|
195
210
|
return true;
|
|
196
211
|
}
|
|
212
|
+
/** The ref naming a registered channel's entry. */
|
|
213
|
+
function refOf(entry) {
|
|
214
|
+
return { serverId: entry.serverId, channelId: entry.descriptor.id };
|
|
215
|
+
}
|
|
197
216
|
/**
|
|
198
217
|
* A channel its server declared inbound-only (SPEC §14.2 `direction`): its
|
|
199
218
|
* messages come in, and the host publishes nothing into it. No publish, from
|
|
@@ -305,7 +324,8 @@ const CHANNEL_TOOL_DEFINITIONS = [
|
|
|
305
324
|
{
|
|
306
325
|
name: 'channel_close',
|
|
307
326
|
description: 'Close a channel. Messages that address you still reach you; what else stops reaching you ' +
|
|
308
|
-
'depends on its server.'
|
|
327
|
+
'depends on its server. You can still speak there. It stays closed until you open it with ' +
|
|
328
|
+
'channel_open: neither speaking there nor subscription policy reopens it.',
|
|
309
329
|
inputSchema: {
|
|
310
330
|
type: 'object',
|
|
311
331
|
properties: {
|
|
@@ -405,11 +425,33 @@ export function connectorText(value) {
|
|
|
405
425
|
const suffix = '… [truncated]';
|
|
406
426
|
return text.length > limit ? text.slice(0, limit - suffix.length) + suffix : text;
|
|
407
427
|
}
|
|
408
|
-
/** One key per
|
|
409
|
-
*
|
|
428
|
+
/** One key per channel, its server and id: unlike joining the two with a
|
|
429
|
+
* separator, no two channels share one, whatever their ids hold. */
|
|
410
430
|
export function destinationKey(to) {
|
|
411
431
|
return JSON.stringify([to.serverId, to.channelId]);
|
|
412
432
|
}
|
|
433
|
+
/** Whether two refs name the same channel. Two absent refs are the same:
|
|
434
|
+
* neither names a channel. */
|
|
435
|
+
export function sameChannel(a, b) {
|
|
436
|
+
if (!a || !b)
|
|
437
|
+
return !a && !b;
|
|
438
|
+
return a.serverId === b.serverId && a.channelId === b.channelId;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* A channel's server-qualified name, `serverId:channelId`. A name given as a
|
|
442
|
+
* string (a tool's channelId, a `>>` target, a host's call) may take this
|
|
443
|
+
* form to pick one server's channel where several servers register the id.
|
|
444
|
+
* It is looked up among the registered pairs, never split at a colon, since
|
|
445
|
+
* ids hold colons (ChannelRegistry.entriesNamed).
|
|
446
|
+
*/
|
|
447
|
+
export function qualifiedChannelName(ref) {
|
|
448
|
+
return `${ref.serverId}:${ref.channelId}`;
|
|
449
|
+
}
|
|
450
|
+
/** A channel told apart by its id and its server, for one no string picks
|
|
451
|
+
* alone: `"b:room" on server "a"`. No input takes it for a name. */
|
|
452
|
+
function channelOnServer(ref) {
|
|
453
|
+
return `${JSON.stringify(ref.channelId)} on server ${JSON.stringify(ref.serverId)}`;
|
|
454
|
+
}
|
|
413
455
|
/**
|
|
414
456
|
* A failed request's words, bounded, for a reason that a caller composes.
|
|
415
457
|
* This host's own words, for a request that wasn't sent or wasn't answered
|
|
@@ -516,27 +558,6 @@ function supersededClause(action, now) {
|
|
|
516
558
|
return 'it was tuned out meanwhile';
|
|
517
559
|
return action === 'close' ? 'a later decision to keep it open came first' : 'a later decision to close it came first';
|
|
518
560
|
}
|
|
519
|
-
/** What ends a tune-out, for a channel tool's answer about a tuned-out
|
|
520
|
-
* channel (`channel` as the answer shows it, quoted): its cancel, with the
|
|
521
|
-
* backlog it delivers, and, for a close, that channel_close closes the
|
|
522
|
-
* channel once the cancel has ended the tune-out. */
|
|
523
|
-
function tunedOutPointer(tool, channel) {
|
|
524
|
-
return `Call tune_out with channelId ${channel} and mode "cancel" for it to resume normal attention; cancelling ` +
|
|
525
|
-
'delivers the diverted backlog' + (tool === 'channel_close' ? ', and channel_close closes it after that.' : '.');
|
|
526
|
-
}
|
|
527
|
-
/** A channel tool's refusal of a tuned-out channel, whose cancel is what ends
|
|
528
|
-
* the tune-out, with the backlog it delivers. As an error result the agent
|
|
529
|
-
* reads the pointer itself; `data` names the refusal for an in-process
|
|
530
|
-
* caller. */
|
|
531
|
-
function tunedOutRefusal(tool, channelId) {
|
|
532
|
-
return {
|
|
533
|
-
success: false,
|
|
534
|
-
isError: true,
|
|
535
|
-
error: `Channel "${channelId}" is tuned out: its messages go to the subconscious, and ${tool} doesn't change that. ` +
|
|
536
|
-
tunedOutPointer(tool, `"${channelId}"`),
|
|
537
|
-
data: { refusal: 'tuned-out', channelId },
|
|
538
|
-
};
|
|
539
|
-
}
|
|
540
561
|
/** A clause as the start of a sentence. */
|
|
541
562
|
function sentenceStart(clause) {
|
|
542
563
|
return clause.charAt(0).toUpperCase() + clause.slice(1);
|
|
@@ -609,6 +630,19 @@ function publishReceiptOutcome(result, destination, at) {
|
|
|
609
630
|
* Closed on purpose: honest provenance means a small, auditable vocabulary,
|
|
610
631
|
* not arbitrary self-description. */
|
|
611
632
|
export const MACHINE_CLOSE_SOURCES = new Set(['subscription-gc', 'housekeeping']);
|
|
633
|
+
/** A close a module made through its own tool call (ctx.callTool) without a
|
|
634
|
+
* machine source, such as connectome-host's mentions mode: a decision the
|
|
635
|
+
* module owns, with its own way back. Speech leaves it closed, as it does a
|
|
636
|
+
* channel the agent closed (speechOpens), but the agent isn't told how to
|
|
637
|
+
* open it, since channel_open alone would undo only part of what the
|
|
638
|
+
* module did. */
|
|
639
|
+
export const MODULE_CLOSE_SOURCE = 'module-tool';
|
|
640
|
+
/** Whether a closed record is a close the agent or a module decided
|
|
641
|
+
* (channel_close, channel_decline, or a module's own close), which speech
|
|
642
|
+
* never undoes. */
|
|
643
|
+
function isCloseDecision(source) {
|
|
644
|
+
return AGENT_DECISION_SOURCES.has(source) || source === MODULE_CLOSE_SOURCE;
|
|
645
|
+
}
|
|
612
646
|
export class ChannelRegistry {
|
|
613
647
|
serverRegistry;
|
|
614
648
|
featureSetManager;
|
|
@@ -623,7 +657,11 @@ export class ChannelRegistry {
|
|
|
623
657
|
takesGlobalFallback;
|
|
624
658
|
serverConnecting;
|
|
625
659
|
store;
|
|
626
|
-
/**
|
|
660
|
+
/**
|
|
661
|
+
* Registered channels, keyed by their server and id (destinationKey): a
|
|
662
|
+
* key that joined the two with a separator would let server `a`'s channel
|
|
663
|
+
* `b:room` and server `a:b`'s channel `room` share one entry.
|
|
664
|
+
*/
|
|
627
665
|
channels = new Map();
|
|
628
666
|
/**
|
|
629
667
|
* Each outgoing stream, by `inferenceId`, with the destinations it has
|
|
@@ -688,10 +726,12 @@ export class ChannelRegistry {
|
|
|
688
726
|
/** The same message's id and thread, used for buildChannelContext. */
|
|
689
727
|
defaultPublishMessageId = null;
|
|
690
728
|
defaultPublishThreadId = undefined;
|
|
691
|
-
/** Per-channel typing indicator timers
|
|
729
|
+
/** Per-channel typing indicator timers, by destinationKey, with the
|
|
730
|
+
* channel each one types in. */
|
|
692
731
|
typingIntervals = new Map();
|
|
693
|
-
/** Per-channel typing metadata — carried on the 7s
|
|
694
|
-
* server keeps getting the same routing hints (e.g.
|
|
732
|
+
/** Per-channel typing metadata, by destinationKey — carried on the 7s
|
|
733
|
+
* refresh so the target server keeps getting the same routing hints (e.g.
|
|
734
|
+
* Zulip topic). */
|
|
695
735
|
typingMetadata = new Map();
|
|
696
736
|
/** Chronicle-projected desired lifecycle state, keyed by server + channel.
|
|
697
737
|
* Provenance is kept so reconcile can tell a pure default (nobody ever
|
|
@@ -719,6 +759,16 @@ export class ChannelRegistry {
|
|
|
719
759
|
// Losing an open receipt does not confirm closure. Keep that distinction
|
|
720
760
|
// private while preserving the public open:boolean availability projection.
|
|
721
761
|
unconfirmedChannelTargets = new WeakSet();
|
|
762
|
+
// Registrations whose last open the server answered for another channel,
|
|
763
|
+
// until an open is answered for this one, or the server registers the
|
|
764
|
+
// channel again, which makes a new registration: a server that doesn't
|
|
765
|
+
// resolve this id may not resolve a publish by it either. A close's answer
|
|
766
|
+
// names no channel, so it doesn't clear the mark, and a retarget keeps the
|
|
767
|
+
// registration and its mark. Read where speech would put words into such a
|
|
768
|
+
// channel without an open (publish). Where the grant loses
|
|
769
|
+
// channels.lifecycle while the connection stays (§6.7), no open can clear
|
|
770
|
+
// the mark until the server reconnects and registers the channel again.
|
|
771
|
+
answeredElsewhere = new WeakSet();
|
|
722
772
|
// Marked entries a close failed for since a receipt or traffic last
|
|
723
773
|
// confirmed them, cleared where the mark is: a failed request's outcome is
|
|
724
774
|
// unknown, so such a close may have closed the channel, where an open,
|
|
@@ -750,7 +800,7 @@ export class ChannelRegistry {
|
|
|
750
800
|
/** Whether a decided close holds words back from a channel: only where
|
|
751
801
|
* the host directs the server's lifecycle. Without that grant the host
|
|
752
802
|
* can't close the channel, and words go into a transport that is open,
|
|
753
|
-
* as the open step
|
|
803
|
+
* as the open step reads it too (speechOpens). */
|
|
754
804
|
closeStands(entry) {
|
|
755
805
|
return this.getDesiredState(entry.serverId, entry.descriptor.id) === 'closed' &&
|
|
756
806
|
this.directsLifecycle(entry.serverId);
|
|
@@ -823,7 +873,8 @@ export class ChannelRegistry {
|
|
|
823
873
|
* is in the connection's words.
|
|
824
874
|
*/
|
|
825
875
|
lifecycleFailureText(action, entry, err, opts) {
|
|
826
|
-
|
|
876
|
+
// As the agent names it back (nameShown), where another server shares its id.
|
|
877
|
+
const channel = this.quotedShown(refOf(entry));
|
|
827
878
|
const tool = action === 'open' ? 'channel_open' : 'channel_close';
|
|
828
879
|
const done = action === 'open' ? 'opened' : 'closed';
|
|
829
880
|
const later = laterClause(entry);
|
|
@@ -835,7 +886,7 @@ export class ChannelRegistry {
|
|
|
835
886
|
}
|
|
836
887
|
const failure = err instanceof ChannelLifecycleError ? err : undefined;
|
|
837
888
|
const now = this.getDesiredState(entry.serverId, entry.descriptor.id);
|
|
838
|
-
const current = this.
|
|
889
|
+
const current = this.entryAt(refOf(entry));
|
|
839
890
|
if (!current) {
|
|
840
891
|
// Its registration may have gone at the server's word or at this
|
|
841
892
|
// host's (removeServer). Only a failure that says which (cancelled,
|
|
@@ -858,13 +909,13 @@ export class ChannelRegistry {
|
|
|
858
909
|
if (action === 'open' ? now !== 'open' : now !== 'closed') {
|
|
859
910
|
if (action === 'open' && now === 'tuned-out') {
|
|
860
911
|
return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_open doesn't ` +
|
|
861
|
-
`change that.
|
|
912
|
+
`change that. ${this.tuneOutEndText(current)}`;
|
|
862
913
|
}
|
|
863
914
|
// channel_close refuses a tuned-out channel too, so calling it again
|
|
864
915
|
// isn't the way: the tune-out's cancel is, and it delivers the backlog.
|
|
865
916
|
if (action === 'close' && now === 'tuned-out') {
|
|
866
917
|
return `Channel ${channel} was tuned out meanwhile: its messages go to the subconscious, and channel_close doesn't ` +
|
|
867
|
-
`change that. ${
|
|
918
|
+
`change that. ${this.tuneOutEndText(current, 'channel_close')}`;
|
|
868
919
|
}
|
|
869
920
|
// The later decision's own work settles the transport, and its call
|
|
870
921
|
// says what became of it, so this says only that this call's change
|
|
@@ -1009,7 +1060,7 @@ export class ChannelRegistry {
|
|
|
1009
1060
|
results.push({ id: String(channel?.id ?? ''), accepted: false, reason: 'invalid descriptor: missing id' });
|
|
1010
1061
|
continue;
|
|
1011
1062
|
}
|
|
1012
|
-
const key =
|
|
1063
|
+
const key = destinationKey({ serverId, channelId: channel.id });
|
|
1013
1064
|
this.registerEntry(key, serverId, channel);
|
|
1014
1065
|
this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
1015
1066
|
registeredIds.push(channel.id);
|
|
@@ -1048,9 +1099,11 @@ export class ChannelRegistry {
|
|
|
1048
1099
|
// we never had is reported, not silently accepted.
|
|
1049
1100
|
if (params.removed) {
|
|
1050
1101
|
for (const channelId of params.removed) {
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1102
|
+
// A server may name the channel by a number: it means the channel
|
|
1103
|
+
// whose id is that number as a string, as it always has.
|
|
1104
|
+
const ref = { serverId, channelId: String(channelId) };
|
|
1105
|
+
const existed = this.channels.delete(destinationKey(ref));
|
|
1106
|
+
this.stopTyping(ref);
|
|
1054
1107
|
addedResults.push({ id: channelId, accepted: existed, reason: existed ? undefined : 'not registered' });
|
|
1055
1108
|
}
|
|
1056
1109
|
}
|
|
@@ -1065,7 +1118,7 @@ export class ChannelRegistry {
|
|
|
1065
1118
|
this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, reason: 'missing id (update)' });
|
|
1066
1119
|
continue;
|
|
1067
1120
|
}
|
|
1068
|
-
const key =
|
|
1121
|
+
const key = destinationKey({ serverId, channelId: channel.id });
|
|
1069
1122
|
const existing = this.channels.get(key);
|
|
1070
1123
|
if (!existing) {
|
|
1071
1124
|
addedResults.push({ id: channel.id, accepted: false, reason: 'not registered — update rejected (§14.5)' });
|
|
@@ -1105,7 +1158,7 @@ export class ChannelRegistry {
|
|
|
1105
1158
|
this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, reason: 'missing id' });
|
|
1106
1159
|
continue;
|
|
1107
1160
|
}
|
|
1108
|
-
const key =
|
|
1161
|
+
const key = destinationKey({ serverId, channelId: channel.id });
|
|
1109
1162
|
this.registerEntry(key, serverId, channel);
|
|
1110
1163
|
this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
1111
1164
|
addedResults.push({ id: channel.id, accepted: true });
|
|
@@ -1175,6 +1228,9 @@ export class ChannelRegistry {
|
|
|
1175
1228
|
// The batch came on the transport open now: a close after this point
|
|
1176
1229
|
// (transportClosed) leaves its traffic nothing to confirm.
|
|
1177
1230
|
const closesAtArrival = this.transportCloses.get(serverId) ?? 0;
|
|
1231
|
+
// And came from a server registered now: one removed since (removeServer)
|
|
1232
|
+
// gives no message the agent replies to (markAccepted).
|
|
1233
|
+
const removalsAtArrival = this.serverRemovals.get(serverId) ?? 0;
|
|
1178
1234
|
for (const message of params.messages) {
|
|
1179
1235
|
if (!message || typeof message.channelId !== 'string' || !message.channelId
|
|
1180
1236
|
|| typeof message.messageId !== 'string' || !message.messageId) {
|
|
@@ -1209,7 +1265,7 @@ export class ChannelRegistry {
|
|
|
1209
1265
|
// exists registers it first; discord's DM case goes through
|
|
1210
1266
|
// ensureChannelRegistered on push/event, which creates a routable
|
|
1211
1267
|
// entry that isn't open, rather than an open self-attested one.
|
|
1212
|
-
const incomingKey =
|
|
1268
|
+
const incomingKey = destinationKey({ serverId, channelId: message.channelId });
|
|
1213
1269
|
if (!this.channels.has(incomingKey)) {
|
|
1214
1270
|
// Loud, coalesced or not, like every other refusal of a message, but
|
|
1215
1271
|
// at most once a minute per channel (RefusalLines): a server that
|
|
@@ -1286,12 +1342,16 @@ export class ChannelRegistry {
|
|
|
1286
1342
|
const incomingEntry = this.channels.get(incomingKey);
|
|
1287
1343
|
const markAccepted = (firstView = false) => {
|
|
1288
1344
|
// Non-conversational markers (a reaction added or removed, an edit, a
|
|
1289
|
-
// deletion
|
|
1345
|
+
// deletion, and a chat:system notice such as a join or the weather)
|
|
1346
|
+
// are ABOUT a message or the place, not one the agent replies to: they
|
|
1290
1347
|
// must not retarget the default publish locus or become
|
|
1291
1348
|
// `context.incoming`'s reply anchor. An edit the coalescer delivered
|
|
1292
1349
|
// as the agent's first view counts as the message it replaced.
|
|
1293
|
-
|
|
1294
|
-
|
|
1350
|
+
// A message the coalescer admits after its server was removed sets no
|
|
1351
|
+
// default: the removal cleared that server's message, and a server
|
|
1352
|
+
// that's gone mustn't displace a live one's default.
|
|
1353
|
+
if (!isMarkerTagged(message.tags, firstView) && (this.serverRemovals.get(serverId) ?? 0) === removalsAtArrival) {
|
|
1354
|
+
this.defaultPublishChannel = { serverId, channelId: message.channelId };
|
|
1295
1355
|
this.defaultPublishMessageId = message.messageId;
|
|
1296
1356
|
this.defaultPublishThreadId = message.threadId;
|
|
1297
1357
|
}
|
|
@@ -1453,7 +1513,9 @@ export class ChannelRegistry {
|
|
|
1453
1513
|
* overwrites this descriptor with the richer one.
|
|
1454
1514
|
*/
|
|
1455
1515
|
ensureChannelRegistered(serverId, channelId, label, extraMetadata) {
|
|
1456
|
-
|
|
1516
|
+
// This server's own registration: another server's channel with the same
|
|
1517
|
+
// id is another channel (ChannelRef), which this one doesn't make.
|
|
1518
|
+
const existing = this.entryAt({ serverId, channelId });
|
|
1457
1519
|
if (existing) {
|
|
1458
1520
|
// Backfill identity onto a bare lazy registration: a DM that first
|
|
1459
1521
|
// arrived with no label/recipient info gets a usable name the next
|
|
@@ -1493,7 +1555,7 @@ export class ChannelRegistry {
|
|
|
1493
1555
|
// the server may still hold a channel an earlier process opened. Counting
|
|
1494
1556
|
// it confirmed closed would answer a close or a decline as already done
|
|
1495
1557
|
// and send nothing, even where the server still delivers.
|
|
1496
|
-
const entry = this.registerEntry(
|
|
1558
|
+
const entry = this.registerEntry(destinationKey({ serverId, channelId }), serverId, {
|
|
1497
1559
|
id: channelId,
|
|
1498
1560
|
type: serverId,
|
|
1499
1561
|
label: label ?? channelId,
|
|
@@ -1512,88 +1574,101 @@ export class ChannelRegistry {
|
|
|
1512
1574
|
// Typing Indicator Management
|
|
1513
1575
|
// ==========================================================================
|
|
1514
1576
|
/**
|
|
1515
|
-
* Start sending typing indicators for a channel
|
|
1577
|
+
* Start sending typing indicators for a channel: the channel a ref names,
|
|
1578
|
+
* or each channel a name given as a string means (entriesNamed). A host
|
|
1579
|
+
* names channels by the ids it keeps (connectome-host's activity module),
|
|
1580
|
+
* and typing posts nothing, so a name two servers' channels answer to
|
|
1581
|
+
* shows typing in both, rather than in neither or in whichever registered
|
|
1582
|
+
* first. Where two servers reach one platform channel, two bots can then
|
|
1583
|
+
* show typing there.
|
|
1516
1584
|
*
|
|
1517
1585
|
* Sends a typing notification immediately and every 7 seconds thereafter.
|
|
1518
1586
|
* Discord typing indicators last ~10s, so 7s keeps them alive.
|
|
1519
1587
|
*
|
|
1520
|
-
* No-op
|
|
1588
|
+
* No-op for a channel already typing, or not registered.
|
|
1521
1589
|
*/
|
|
1522
|
-
startTyping(
|
|
1590
|
+
startTyping(channel, metadata) {
|
|
1591
|
+
const refs = typeof channel === 'string' ? this.entriesNamed(channel).map(refOf) : [channel];
|
|
1592
|
+
for (const ref of refs)
|
|
1593
|
+
this.startTypingAt(ref, metadata);
|
|
1594
|
+
}
|
|
1595
|
+
startTypingAt(ref, metadata) {
|
|
1596
|
+
// A channel types only while it's registered, and keeps no metadata
|
|
1597
|
+
// otherwise: each removal of a channel or a server stops its typing
|
|
1598
|
+
// (stopTyping), which drops the metadata too, and stopAll clears both.
|
|
1599
|
+
if (!this.entryAt(ref))
|
|
1600
|
+
return;
|
|
1601
|
+
const key = destinationKey(ref);
|
|
1523
1602
|
const metadataChanged = metadata !== undefined &&
|
|
1524
|
-
!shallowEqualRecord(this.typingMetadata.get(
|
|
1603
|
+
!shallowEqualRecord(this.typingMetadata.get(key), metadata);
|
|
1525
1604
|
if (metadata) {
|
|
1526
|
-
this.typingMetadata.set(
|
|
1605
|
+
this.typingMetadata.set(key, metadata);
|
|
1527
1606
|
}
|
|
1528
|
-
if (this.typingIntervals.has(
|
|
1607
|
+
if (this.typingIntervals.has(key)) {
|
|
1529
1608
|
// Already typing. If the caller supplied new routing metadata (e.g. the
|
|
1530
1609
|
// relevant Zulip topic just moved because a newer message arrived),
|
|
1531
1610
|
// dispatch an immediate refresh so the server sees the new routing
|
|
1532
1611
|
// within this request instead of waiting up to TYPING_INTERVAL_MS for
|
|
1533
1612
|
// the next tick.
|
|
1534
|
-
if (metadataChanged)
|
|
1535
|
-
|
|
1536
|
-
if (entry) {
|
|
1537
|
-
this.sendTypingNotification(entry.serverId, channelId);
|
|
1538
|
-
}
|
|
1539
|
-
}
|
|
1540
|
-
return;
|
|
1541
|
-
}
|
|
1542
|
-
// Find the channel entry and its server
|
|
1543
|
-
const entry = this.findChannelEntry(channelId);
|
|
1544
|
-
if (!entry) {
|
|
1613
|
+
if (metadataChanged)
|
|
1614
|
+
this.sendTypingNotification(ref);
|
|
1545
1615
|
return;
|
|
1546
1616
|
}
|
|
1547
1617
|
// Send typing immediately
|
|
1548
|
-
this.sendTypingNotification(
|
|
1618
|
+
this.sendTypingNotification(ref);
|
|
1549
1619
|
// Set up interval — pulls the latest metadata on each tick so mid-stream
|
|
1550
1620
|
// updates (e.g. a newer incoming message switching the relevant topic)
|
|
1551
1621
|
// take effect on the next refresh.
|
|
1552
1622
|
const interval = setInterval(() => {
|
|
1553
|
-
this.sendTypingNotification(
|
|
1623
|
+
this.sendTypingNotification(ref);
|
|
1554
1624
|
}, TYPING_INTERVAL_MS);
|
|
1555
|
-
this.typingIntervals.set(
|
|
1625
|
+
this.typingIntervals.set(key, { ref, interval });
|
|
1556
1626
|
}
|
|
1557
1627
|
/**
|
|
1558
1628
|
* Stop sending typing indicators.
|
|
1559
1629
|
*
|
|
1560
|
-
*
|
|
1561
|
-
*
|
|
1630
|
+
* Given a ref, stops typing in that channel. Given a name as a string,
|
|
1631
|
+
* stops typing in each channel the name means, by its id or its
|
|
1632
|
+
* server-qualified name (entriesNamed), as startTyping started it: a
|
|
1633
|
+
* channel types only while it's registered, since startTypingAt starts
|
|
1634
|
+
* none that isn't and each removal stops its channel's typing. Given
|
|
1635
|
+
* nothing, stops all typing indicators.
|
|
1562
1636
|
*/
|
|
1563
|
-
stopTyping(
|
|
1564
|
-
if (
|
|
1565
|
-
|
|
1566
|
-
|
|
1637
|
+
stopTyping(channel) {
|
|
1638
|
+
if (channel === undefined) {
|
|
1639
|
+
// Clear all typing intervals and dispatch stop for each known channel
|
|
1640
|
+
const running = [...this.typingIntervals.values()];
|
|
1641
|
+
for (const { interval } of running)
|
|
1567
1642
|
clearInterval(interval);
|
|
1568
|
-
|
|
1643
|
+
this.typingIntervals.clear();
|
|
1644
|
+
for (const { ref } of running)
|
|
1645
|
+
this.sendTypingStop(ref);
|
|
1646
|
+
this.typingMetadata.clear();
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
const refs = typeof channel === 'string' ? this.entriesNamed(channel).map(refOf) : [channel];
|
|
1650
|
+
for (const ref of refs) {
|
|
1651
|
+
const key = destinationKey(ref);
|
|
1652
|
+
const typing = this.typingIntervals.get(key);
|
|
1653
|
+
if (typing) {
|
|
1654
|
+
clearInterval(typing.interval);
|
|
1655
|
+
this.typingIntervals.delete(key);
|
|
1569
1656
|
// Dispatch an explicit 'stop' so servers that support it (e.g. Zulip)
|
|
1570
1657
|
// clear the indicator immediately rather than waiting for auto-expire.
|
|
1571
1658
|
// Metadata still carries the routing hint so the stop hits the same
|
|
1572
|
-
// topic/thread as the start. Guarded by
|
|
1659
|
+
// topic/thread as the start. Guarded by the interval: matches the
|
|
1573
1660
|
// global-clear branch's semantics, and keeps defensive stopTyping(ch)
|
|
1574
1661
|
// calls from spamming stops at a server that never saw a start.
|
|
1575
|
-
|
|
1576
|
-
if (entry && this.sendTypingFn) {
|
|
1577
|
-
this.sendTypingFn(entry.serverId, channelId, this.typingMetadata.get(channelId), 'stop');
|
|
1578
|
-
}
|
|
1662
|
+
this.sendTypingStop(ref);
|
|
1579
1663
|
}
|
|
1580
|
-
this.typingMetadata.delete(
|
|
1664
|
+
this.typingMetadata.delete(key);
|
|
1581
1665
|
}
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
this.typingIntervals.clear();
|
|
1589
|
-
if (this.sendTypingFn) {
|
|
1590
|
-
for (const id of channels) {
|
|
1591
|
-
const entry = this.findChannelEntry(id);
|
|
1592
|
-
if (entry)
|
|
1593
|
-
this.sendTypingFn(entry.serverId, id, this.typingMetadata.get(id), 'stop');
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
|
-
this.typingMetadata.clear();
|
|
1666
|
+
}
|
|
1667
|
+
/** The explicit 'stop' for a channel whose typing ended, while its
|
|
1668
|
+
* server still registers it. */
|
|
1669
|
+
sendTypingStop(ref) {
|
|
1670
|
+
if (this.sendTypingFn && this.entryAt(ref)) {
|
|
1671
|
+
this.sendTypingFn(ref.serverId, ref.channelId, this.typingMetadata.get(destinationKey(ref)), 'stop');
|
|
1597
1672
|
}
|
|
1598
1673
|
}
|
|
1599
1674
|
// ==========================================================================
|
|
@@ -1608,10 +1683,6 @@ export class ChannelRegistry {
|
|
|
1608
1683
|
getDefaultPublishChannel() {
|
|
1609
1684
|
return this.defaultPublishChannel;
|
|
1610
1685
|
}
|
|
1611
|
-
/**
|
|
1612
|
-
* Get the descriptor for a channel by its channelId (first match across
|
|
1613
|
-
* servers). Used by the conversation router for DM classification.
|
|
1614
|
-
*/
|
|
1615
1686
|
/**
|
|
1616
1687
|
* RFC-006 §3.2: is `channelId` registered BY `serverId` through a server
|
|
1617
1688
|
* declaration (channels/register, channels/changed) or an authorized open?
|
|
@@ -1620,28 +1691,22 @@ export class ChannelRegistry {
|
|
|
1620
1691
|
* registration.
|
|
1621
1692
|
*/
|
|
1622
1693
|
isDeclaredChannel(serverId, channelId) {
|
|
1623
|
-
const entry = this.
|
|
1694
|
+
const entry = this.entryAt({ serverId, channelId });
|
|
1624
1695
|
return !!entry && !pushPlaceholder(entry);
|
|
1625
1696
|
}
|
|
1626
|
-
|
|
1627
|
-
|
|
1697
|
+
/** A registered channel's descriptor. Used by the conversation router for
|
|
1698
|
+
* DM classification. */
|
|
1699
|
+
getDescriptor(ref) {
|
|
1700
|
+
return this.entryAt(ref)?.descriptor;
|
|
1628
1701
|
}
|
|
1629
1702
|
/** The registered label of `channelId` on exactly `serverId` (no
|
|
1630
1703
|
* cross-server first match), or undefined when it has none. */
|
|
1631
1704
|
getChannelLabel(serverId, channelId) {
|
|
1632
|
-
const label = this.
|
|
1705
|
+
const label = this.entryAt({ serverId, channelId })?.descriptor.label;
|
|
1633
1706
|
return typeof label === 'string' && label.length > 0 ? label : undefined;
|
|
1634
1707
|
}
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
* register that id (findChannelEntry). The map's key joins the two ids
|
|
1638
|
-
* with a colon, so another server's entry can sit under a key: it has to
|
|
1639
|
-
* be this server's (as in streamingServerAt). */
|
|
1640
|
-
isChannelOpen(channelId, serverId) {
|
|
1641
|
-
if (serverId === undefined)
|
|
1642
|
-
return this.findChannelEntry(channelId)?.open === true;
|
|
1643
|
-
const entry = this.channels.get(`${serverId}:${channelId}`);
|
|
1644
|
-
return entry?.serverId === serverId && entry.open === true;
|
|
1708
|
+
isChannelOpen(ref) {
|
|
1709
|
+
return this.entryAt(ref)?.open === true;
|
|
1645
1710
|
}
|
|
1646
1711
|
getDesiredState(serverId, channelId) {
|
|
1647
1712
|
return this.desiredStates.get(this.lifecycleKey(serverId, channelId))?.state;
|
|
@@ -1679,18 +1744,21 @@ export class ChannelRegistry {
|
|
|
1679
1744
|
* decision it actually is. Absent origin is treated as agent-origin (the
|
|
1680
1745
|
* untrusted-safe default).
|
|
1681
1746
|
*/
|
|
1682
|
-
async handleChannelToolCall(toolName, input, origin
|
|
1747
|
+
async handleChannelToolCall(toolName, input, origin,
|
|
1748
|
+
/** Filled with a channel_publish's outcome once its request is
|
|
1749
|
+
* attempted, so the caller learns where it was delivered. */
|
|
1750
|
+
seen) {
|
|
1683
1751
|
switch (toolName) {
|
|
1684
1752
|
case 'channel_list':
|
|
1685
1753
|
return this.handleToolList();
|
|
1686
1754
|
case 'channel_open':
|
|
1687
|
-
return this.handleToolOpen(input);
|
|
1755
|
+
return this.handleToolOpen(input, origin?.kind === 'agent' ? origin.own : undefined);
|
|
1688
1756
|
case 'channel_close':
|
|
1689
1757
|
return this.handleToolClose(input, origin);
|
|
1690
1758
|
case 'channel_decline':
|
|
1691
|
-
return this.handleToolDecline(input);
|
|
1759
|
+
return this.handleToolDecline(input, origin?.kind === 'agent' ? origin.own : undefined);
|
|
1692
1760
|
case 'channel_publish':
|
|
1693
|
-
return this.handleToolPublish(input, origin);
|
|
1761
|
+
return this.handleToolPublish(input, origin, seen);
|
|
1694
1762
|
case 'think':
|
|
1695
1763
|
return this.handleToolThink(input);
|
|
1696
1764
|
case 'journal':
|
|
@@ -1711,12 +1779,39 @@ export class ChannelRegistry {
|
|
|
1711
1779
|
// Channel Context for beforeInference
|
|
1712
1780
|
// ==========================================================================
|
|
1713
1781
|
/**
|
|
1714
|
-
*
|
|
1715
|
-
*
|
|
1716
|
-
*
|
|
1782
|
+
* The message the agent is replying to, with its channel: the most recent
|
|
1783
|
+
* inbound message that isn't a reaction, reaction removal, edit or deletion
|
|
1784
|
+
* marker, apart from an edit the coalescer delivers on acceptance as the
|
|
1785
|
+
* agent's first view. Left process-global: per-channel inbound tracking
|
|
1786
|
+
* (the right messageId for a fork's own channel) is a separate concern from
|
|
1787
|
+
* the outbound routing fix. An ephemeral run has none (takesGlobalFallback),
|
|
1788
|
+
* so a subagent's refusal marks no one else's channel. Nor has a turn that
|
|
1789
|
+
* deliberately has no channel (activeChannelResolver's null): woken for a
|
|
1790
|
+
* channel id two servers register, it isn't replying to the latest inbound
|
|
1791
|
+
* message, which may be anywhere. A fork's turn always has a channel, its
|
|
1792
|
+
* home, so it's taken to be replying to the latest inbound message as every
|
|
1793
|
+
* fork's turn is, in explicit mode as in locus mode, where its locus is
|
|
1794
|
+
* its home.
|
|
1717
1795
|
*/
|
|
1718
|
-
|
|
1719
|
-
|
|
1796
|
+
incomingFor(agentName) {
|
|
1797
|
+
if (agentName !== undefined && this.activeChannelResolver?.(agentName) === null && !this.homeChannelResolver?.(agentName)) {
|
|
1798
|
+
return undefined;
|
|
1799
|
+
}
|
|
1800
|
+
const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
|
|
1801
|
+
if (!fallback || !this.defaultPublishMessageId)
|
|
1802
|
+
return undefined;
|
|
1803
|
+
return { channel: fallback, messageId: this.defaultPublishMessageId, threadId: this.defaultPublishThreadId };
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* The channel context one server is told for an agent's inference
|
|
1807
|
+
* (context/beforeInference, §14.4). Each server is told only of its own
|
|
1808
|
+
* channels: ids are unique only within one server's connection (§14.2), so
|
|
1809
|
+
* another server's channel named by its id alone could read as this
|
|
1810
|
+
* server's own, and §14.4 leaves the field to the host. So `incoming` and
|
|
1811
|
+
* `defaultOutgoing` appear only where that channel is `serverId`'s, and
|
|
1812
|
+
* `candidates` lists only its open channels. Undefined when none applies.
|
|
1813
|
+
*/
|
|
1814
|
+
buildChannelContext(agentName, serverId) {
|
|
1720
1815
|
// Resolve the outbound locus the SAME way routeSpeech does, so the agent is
|
|
1721
1816
|
// told the channel its speech will actually land in (none, when its server
|
|
1722
1817
|
// declared it inbound-only, since speech isn't posted there): a conversation
|
|
@@ -1733,37 +1828,32 @@ export class ChannelRegistry {
|
|
|
1733
1828
|
// the latest inbound message's channel as its outgoing one, nor that
|
|
1734
1829
|
// message as the one it is replying to.
|
|
1735
1830
|
const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
return undefined;
|
|
1739
|
-
}
|
|
1831
|
+
// A turn that deliberately has no channel (null) takes no fallback.
|
|
1832
|
+
const outgoing = home ?? (active !== undefined ? active : fallback);
|
|
1740
1833
|
const context = {};
|
|
1741
|
-
// Incoming: the
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
// agent is replying to).
|
|
1745
|
-
// Left process-global — per-channel inbound tracking (the right messageId for
|
|
1746
|
-
// a fork's own channel) is a separate concern from the outbound routing fix.
|
|
1747
|
-
if (fallback && this.defaultPublishMessageId) {
|
|
1834
|
+
// Incoming: the message the agent is replying to (incomingFor).
|
|
1835
|
+
const incoming = this.incomingFor(agentName);
|
|
1836
|
+
if (incoming && incoming.channel.serverId === serverId) {
|
|
1748
1837
|
context.incoming = {
|
|
1749
|
-
channelId:
|
|
1750
|
-
messageId:
|
|
1751
|
-
threadId:
|
|
1838
|
+
channelId: incoming.channel.channelId,
|
|
1839
|
+
messageId: incoming.messageId,
|
|
1840
|
+
threadId: incoming.threadId,
|
|
1752
1841
|
};
|
|
1753
1842
|
}
|
|
1754
1843
|
// Default outgoing: the resolved outbound locus (home channel for forks),
|
|
1755
1844
|
// unless its server declared it inbound-only: routeSpeech publishes nothing
|
|
1756
1845
|
// there, so it isn't where the agent's speech goes.
|
|
1757
|
-
if (outgoing && !this.inboundOnlyServer(outgoing)) {
|
|
1846
|
+
if (outgoing && outgoing.serverId === serverId && !this.inboundOnlyServer(outgoing)) {
|
|
1758
1847
|
context.defaultOutgoing = {
|
|
1759
|
-
channelId: outgoing,
|
|
1848
|
+
channelId: outgoing.channelId,
|
|
1760
1849
|
};
|
|
1761
1850
|
}
|
|
1762
|
-
// Candidates:
|
|
1763
|
-
|
|
1764
|
-
|
|
1851
|
+
// Candidates: this server's open channels.
|
|
1852
|
+
const candidates = this.getOpenChannels().filter((e) => e.serverId === serverId).map((e) => e.descriptor.id);
|
|
1853
|
+
if (candidates.length > 0) {
|
|
1854
|
+
context.candidates = candidates;
|
|
1765
1855
|
}
|
|
1766
|
-
return context;
|
|
1856
|
+
return Object.keys(context).length > 0 ? context : undefined;
|
|
1767
1857
|
}
|
|
1768
1858
|
// ==========================================================================
|
|
1769
1859
|
// Lifecycle
|
|
@@ -1782,12 +1872,18 @@ export class ChannelRegistry {
|
|
|
1782
1872
|
if (entry.serverId !== serverId)
|
|
1783
1873
|
continue;
|
|
1784
1874
|
this.channels.delete(key);
|
|
1785
|
-
this.stopTyping(entry
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1875
|
+
this.stopTyping(refOf(entry));
|
|
1876
|
+
}
|
|
1877
|
+
// The default channel's ref stays, as a channels/changed removal leaves
|
|
1878
|
+
// it: a call that would default to it is refused as the default gone,
|
|
1879
|
+
// naming it, rather than told there's no default yet, and another
|
|
1880
|
+
// server's channel with its id isn't taken for it. Its message and
|
|
1881
|
+
// thread go, as on main, so no message on a server that's gone is given
|
|
1882
|
+
// as the one an agent replies to (incomingFor), to that server added
|
|
1883
|
+
// back or to a refusal's reaction.
|
|
1884
|
+
if (this.defaultPublishChannel?.serverId === serverId) {
|
|
1885
|
+
this.defaultPublishMessageId = null;
|
|
1886
|
+
this.defaultPublishThreadId = undefined;
|
|
1791
1887
|
}
|
|
1792
1888
|
// Desired state and migration markers deliberately survive disconnects.
|
|
1793
1889
|
}
|
|
@@ -1831,11 +1927,13 @@ export class ChannelRegistry {
|
|
|
1831
1927
|
* Stop all typing intervals and clear all channel registrations.
|
|
1832
1928
|
*/
|
|
1833
1929
|
stopAll() {
|
|
1834
|
-
// Clear all typing intervals
|
|
1835
|
-
|
|
1930
|
+
// Clear all typing intervals, and their metadata, which a channel
|
|
1931
|
+
// registered again later would otherwise send
|
|
1932
|
+
for (const { interval } of this.typingIntervals.values()) {
|
|
1836
1933
|
clearInterval(interval);
|
|
1837
1934
|
}
|
|
1838
1935
|
this.typingIntervals.clear();
|
|
1936
|
+
this.typingMetadata.clear();
|
|
1839
1937
|
// Clear channels map
|
|
1840
1938
|
this.channels.clear();
|
|
1841
1939
|
// Reset default publish tracking
|
|
@@ -2229,17 +2327,30 @@ export class ChannelRegistry {
|
|
|
2229
2327
|
setDesiredState(serverId, channelId, desired, source) {
|
|
2230
2328
|
const key = this.lifecycleKey(serverId, channelId);
|
|
2231
2329
|
const current = this.desiredStates.get(key);
|
|
2330
|
+
// A machine's close isn't a decision: of a channel with no record (one
|
|
2331
|
+
// registered lazily, or on a server whose grant lacks channels.lifecycle),
|
|
2332
|
+
// it records the default that channel already had, so policy and
|
|
2333
|
+
// bootstrap can still lift it (#417).
|
|
2334
|
+
const recorded = !current && desired === 'closed' && MACHINE_CLOSE_SOURCES.has(source) ? 'default-closed' : source;
|
|
2232
2335
|
// A decision that records the state already on record changes nothing,
|
|
2233
|
-
// so it writes nothing,
|
|
2234
|
-
// channel_close or channel_decline)
|
|
2235
|
-
// then says the agent decided,
|
|
2236
|
-
//
|
|
2237
|
-
//
|
|
2238
|
-
//
|
|
2239
|
-
//
|
|
2240
|
-
// the only decision of the same
|
|
2241
|
-
// record here.
|
|
2242
|
-
|
|
2336
|
+
// so it writes nothing, with two exceptions. The agent's own decision
|
|
2337
|
+
// (channel_open, channel_close or channel_decline) records itself over a
|
|
2338
|
+
// record that isn't the agent's. The record then says the agent decided,
|
|
2339
|
+
// which is all handleToolClose's explicit-open guard and
|
|
2340
|
+
// ensureInitialDesiredState's lift of a default ask of it. openChannelNow
|
|
2341
|
+
// depends on it too: a failed open by speech or a reply tells a decision
|
|
2342
|
+
// made during its round-trip by the record no longer being the object it
|
|
2343
|
+
// wrote, and the agent's channel_open, the only decision of the same
|
|
2344
|
+
// state that can be made then, gets a new record here. And the latest
|
|
2345
|
+
// deliberate close wins: the agent's close or decline, or a module's
|
|
2346
|
+
// close, records itself over any closed record from another source,
|
|
2347
|
+
// another deliberate close included, so a decline after a close is the
|
|
2348
|
+
// decision on record. Speech opens no channel whose close was decided
|
|
2349
|
+
// (speechOpens). A machine's close never replaces a closed record, so
|
|
2350
|
+
// what nobody decided stays liftable.
|
|
2351
|
+
const agentOverOther = AGENT_DECISION_SOURCES.has(source) && !AGENT_DECISION_SOURCES.has(current?.source ?? '');
|
|
2352
|
+
const latestClose = desired === 'closed' && isCloseDecision(source) && current?.source !== source;
|
|
2353
|
+
if (current?.state === desired && !agentOverOther && !latestClose) {
|
|
2243
2354
|
return;
|
|
2244
2355
|
}
|
|
2245
2356
|
this.appendLifecycleEvent({
|
|
@@ -2247,10 +2358,10 @@ export class ChannelRegistry {
|
|
|
2247
2358
|
serverId,
|
|
2248
2359
|
channelId,
|
|
2249
2360
|
desired,
|
|
2250
|
-
source,
|
|
2361
|
+
source: recorded,
|
|
2251
2362
|
timestamp: new Date().toISOString(),
|
|
2252
2363
|
});
|
|
2253
|
-
this.desiredStates.set(key, { state: desired, source });
|
|
2364
|
+
this.desiredStates.set(key, { state: desired, source: recorded });
|
|
2254
2365
|
}
|
|
2255
2366
|
// ==========================================================================
|
|
2256
2367
|
// Tune-out state (issue #77) — durable in the lifecycle log
|
|
@@ -2333,7 +2444,7 @@ export class ChannelRegistry {
|
|
|
2333
2444
|
return this.getTuneOutState(serverId, channelId) !== null &&
|
|
2334
2445
|
!this.channelLifecycleTails.has(this.lifecycleKey(serverId, channelId)) &&
|
|
2335
2446
|
this.serverReach(serverId) === 'up' &&
|
|
2336
|
-
this.tuneOutReadsOpen(this.channels.get(
|
|
2447
|
+
this.tuneOutReadsOpen(this.channels.get(destinationKey({ serverId, channelId })));
|
|
2337
2448
|
}
|
|
2338
2449
|
/**
|
|
2339
2450
|
* Whether a tune-out takes an entry's transport as open: its open flag,
|
|
@@ -2355,7 +2466,7 @@ export class ChannelRegistry {
|
|
|
2355
2466
|
return entry !== undefined && entry.open && !this.failedCloseTargets.has(entry);
|
|
2356
2467
|
}
|
|
2357
2468
|
async openTuneOutTransport(serverId, channelId, epochId) {
|
|
2358
|
-
const channelKey =
|
|
2469
|
+
const channelKey = destinationKey({ serverId, channelId });
|
|
2359
2470
|
const lifecycleKey = this.lifecycleKey(serverId, channelId);
|
|
2360
2471
|
const inEffect = () => epochId !== undefined && this.getTuneOutState(serverId, channelId)?.params.epochId === epochId;
|
|
2361
2472
|
// "May not": a failed request leaves its outcome unknown, and an open
|
|
@@ -2558,29 +2669,45 @@ export class ChannelRegistry {
|
|
|
2558
2669
|
* the resident matches none. A refusal of the destination before anything
|
|
2559
2670
|
* went out carries its `reason` (PublishRefusalReason), so a caller can act
|
|
2560
2671
|
* on why nothing was posted without reading the words.
|
|
2672
|
+
*
|
|
2673
|
+
* The channel is a ref, or a name given as a string, which resolves as
|
|
2674
|
+
* channel_publish's channelId does: one channel the name means, which may
|
|
2675
|
+
* be server-qualified, and a name two servers' channels answer to is
|
|
2676
|
+
* refused with them as candidates (`shared-id`). A host keeps channels by
|
|
2677
|
+
* their ids (connectome-host's notices module). Where several servers
|
|
2678
|
+
* register an id, a ref picks one server's channel, as a qualified name does
|
|
2679
|
+
* where it names one channel alone.
|
|
2561
2680
|
*/
|
|
2562
|
-
async publishForAgent(
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2681
|
+
async publishForAgent(channel, text, agentName) {
|
|
2682
|
+
const input = typeof channel === 'string'
|
|
2683
|
+
? { channelId: channel, content: text }
|
|
2684
|
+
: { channelId: channel.channelId, serverId: channel.serverId, content: text };
|
|
2566
2685
|
const seen = {};
|
|
2567
|
-
const result = await this.handleToolPublish(
|
|
2568
|
-
|
|
2686
|
+
const result = await this.handleToolPublish(input, undefined, seen, 'speak_in_channel');
|
|
2687
|
+
// The destination the publish resolved before it awaited, which the
|
|
2688
|
+
// outcome carries: the channel may be removed while the reply is pending.
|
|
2689
|
+
const destination = seen.outcome?.destination;
|
|
2690
|
+
if (seen.outcome?.status === 'delivered' && destination) {
|
|
2569
2691
|
this.emitTraceFn({
|
|
2570
2692
|
type: 'mcpl:speech-routed',
|
|
2571
2693
|
conversationId: agentName,
|
|
2572
|
-
serverId,
|
|
2573
|
-
channelId,
|
|
2694
|
+
serverId: destination.serverId,
|
|
2695
|
+
channelId: destination.channelId,
|
|
2574
2696
|
delivered: true,
|
|
2575
2697
|
textLen: text.length,
|
|
2576
2698
|
text,
|
|
2577
2699
|
});
|
|
2578
2700
|
}
|
|
2579
2701
|
else {
|
|
2702
|
+
// The channel the publish went to, or that the call named before a
|
|
2703
|
+
// refusal, or, where nothing resolved, the server the caller gave, with
|
|
2704
|
+
// the name it gave.
|
|
2705
|
+
const named = destination ?? seen.resolved;
|
|
2580
2706
|
this.emitTraceFn({
|
|
2581
2707
|
type: 'mcpl:speech-route-failed',
|
|
2582
2708
|
conversationId: agentName,
|
|
2583
|
-
|
|
2709
|
+
...(named ? { serverId: named.serverId } : 'serverId' in input ? { serverId: input.serverId } : {}),
|
|
2710
|
+
channelId: named?.channelId ?? input.channelId,
|
|
2584
2711
|
// The publish's own reason and the connector's detail, as
|
|
2585
2712
|
// deliverSpeech's trace gives them, or the refusal before any publish.
|
|
2586
2713
|
reason: seen.outcome?.reason ?? result.error ?? 'the publish failed',
|
|
@@ -2690,7 +2817,7 @@ export class ChannelRegistry {
|
|
|
2690
2817
|
}
|
|
2691
2818
|
/** One queued lifecycle operation, run when its turn in the queue comes. */
|
|
2692
2819
|
async runLifecycleOperation(serverId, channelId, removals, options) {
|
|
2693
|
-
const channelKey =
|
|
2820
|
+
const channelKey = destinationKey({ serverId, channelId });
|
|
2694
2821
|
const lifecycleKey = this.lifecycleKey(serverId, channelId);
|
|
2695
2822
|
try {
|
|
2696
2823
|
let attempts = 0;
|
|
@@ -2806,6 +2933,7 @@ export class ChannelRegistry {
|
|
|
2806
2933
|
// its sends either.
|
|
2807
2934
|
if (substitute !== undefined) {
|
|
2808
2935
|
this.invalidateTransportConfirmation(entry);
|
|
2936
|
+
this.answeredElsewhere.add(entry);
|
|
2809
2937
|
throw new ChannelLifecycleError(`the "${serverId}" server answered for ` +
|
|
2810
2938
|
(substitute.kind === 'other' ? 'a different channel' : 'a channel whose id isn\'t a string') +
|
|
2811
2939
|
', so nothing in its answer is used', 'substituted');
|
|
@@ -2813,6 +2941,8 @@ export class ChannelRegistry {
|
|
|
2813
2941
|
if (open)
|
|
2814
2942
|
this.applyOpenDeclaration(entry, result);
|
|
2815
2943
|
entry.open = open;
|
|
2944
|
+
if (open)
|
|
2945
|
+
this.answeredElsewhere.delete(entry);
|
|
2816
2946
|
this.unconfirmedChannelTargets.delete(entry);
|
|
2817
2947
|
this.failedCloseTargets.delete(entry);
|
|
2818
2948
|
const now = this.getDesiredState(serverId, channelId);
|
|
@@ -2865,7 +2995,7 @@ export class ChannelRegistry {
|
|
|
2865
2995
|
* doesn't know needs nothing: §14.5 refuses its traffic.
|
|
2866
2996
|
*/
|
|
2867
2997
|
closeSubstitutedChannel(serverId, channelId) {
|
|
2868
|
-
if (!this.channels.has(
|
|
2998
|
+
if (!this.channels.has(destinationKey({ serverId, channelId })))
|
|
2869
2999
|
return;
|
|
2870
3000
|
if (this.getDesiredState(serverId, channelId) !== 'closed')
|
|
2871
3001
|
return;
|
|
@@ -2884,7 +3014,7 @@ export class ChannelRegistry {
|
|
|
2884
3014
|
this.applyDesiredChannelState(serverId, channelId).catch((err) => {
|
|
2885
3015
|
// Removal while queued is cancellation, as in reconcileChannels, and
|
|
2886
3016
|
// exhausted convergence emitted its own diagnostic.
|
|
2887
|
-
if (!this.channels.has(
|
|
3017
|
+
if (!this.channels.has(destinationKey({ serverId, channelId })) ||
|
|
2888
3018
|
(err instanceof ChannelLifecycleError && (err.kind === 'cancelled' || err.kind === 'converge')))
|
|
2889
3019
|
return;
|
|
2890
3020
|
this.emitTraceFn({
|
|
@@ -2927,7 +3057,7 @@ export class ChannelRegistry {
|
|
|
2927
3057
|
if (this.channelLifecycleTails.get(lifecycleKey) === pending)
|
|
2928
3058
|
break;
|
|
2929
3059
|
}
|
|
2930
|
-
const current = this.
|
|
3060
|
+
const current = this.entryAt({ serverId: entry.serverId, channelId: entry.descriptor.id });
|
|
2931
3061
|
if (!current)
|
|
2932
3062
|
throw new ChannelLifecycleError(CHANNEL_REMOVED_CLAUSE, 'removed');
|
|
2933
3063
|
// A close that stands is what the words met, whatever became of an open,
|
|
@@ -2964,7 +3094,7 @@ export class ChannelRegistry {
|
|
|
2964
3094
|
// Record all admissions before waiting on any channel. A slow first RPC
|
|
2965
3095
|
// must not delay intent for the rest of this registration batch.
|
|
2966
3096
|
for (const channel of channels) {
|
|
2967
|
-
const entry = this.
|
|
3097
|
+
const entry = this.entryAt({ serverId, channelId: channel.id });
|
|
2968
3098
|
if (entry && this.ensureInitialDesiredState(serverId, entry.descriptor)) {
|
|
2969
3099
|
policyOpened.push({ channelId: channel.id, label: entry.descriptor.label });
|
|
2970
3100
|
}
|
|
@@ -2987,7 +3117,7 @@ export class ChannelRegistry {
|
|
|
2987
3117
|
// Each channel has its own lifecycle queue, so a held RPC for one (up to
|
|
2988
3118
|
// its timeout, times its attempts) doesn't delay its siblings.
|
|
2989
3119
|
await Promise.allSettled(channels.map(async (channel) => {
|
|
2990
|
-
if (!this.channels.has(
|
|
3120
|
+
if (!this.channels.has(destinationKey({ serverId, channelId: channel.id })))
|
|
2991
3121
|
return;
|
|
2992
3122
|
try {
|
|
2993
3123
|
await this.applyDesiredChannelState(serverId, channel.id);
|
|
@@ -2996,7 +3126,7 @@ export class ChannelRegistry {
|
|
|
2996
3126
|
// Removal while queued is cancellation, not a failed subscription.
|
|
2997
3127
|
// Exhausted convergence and a substituted answer already emitted
|
|
2998
3128
|
// their own diagnostics.
|
|
2999
|
-
if (!this.channels.has(
|
|
3129
|
+
if (!this.channels.has(destinationKey({ serverId, channelId: channel.id })) ||
|
|
3000
3130
|
(err instanceof ChannelLifecycleError &&
|
|
3001
3131
|
(err.kind === 'cancelled' || err.kind === 'converge' || err.kind === 'substituted')))
|
|
3002
3132
|
return;
|
|
@@ -3019,14 +3149,14 @@ export class ChannelRegistry {
|
|
|
3019
3149
|
* Uses the sendTypingFn callback if provided. If not, this is a no-op
|
|
3020
3150
|
* (typing timer lifecycle is still managed for when the callback is wired).
|
|
3021
3151
|
*/
|
|
3022
|
-
sendTypingNotification(
|
|
3152
|
+
sendTypingNotification(ref) {
|
|
3023
3153
|
// §14.1: channels/typing requires channels.typing in the grant. Silent
|
|
3024
3154
|
// skip — a typing indicator is cosmetic and per-7s, so a diagnostic per
|
|
3025
3155
|
// tick would be noise.
|
|
3026
|
-
if (!CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.typing'))
|
|
3156
|
+
if (!CapabilityGrant.of(this.serverRegistry.getServer(ref.serverId)).has('channels.typing'))
|
|
3027
3157
|
return;
|
|
3028
3158
|
if (this.sendTypingFn) {
|
|
3029
|
-
this.sendTypingFn(serverId, channelId, this.typingMetadata.get(
|
|
3159
|
+
this.sendTypingFn(ref.serverId, ref.channelId, this.typingMetadata.get(destinationKey(ref)));
|
|
3030
3160
|
}
|
|
3031
3161
|
// TODO: When server-connection exposes a public sendNotification or
|
|
3032
3162
|
// sendTyping method, wire it here directly instead of using a callback.
|
|
@@ -3040,78 +3170,277 @@ export class ChannelRegistry {
|
|
|
3040
3170
|
.filter((e) => e.serverId === serverId)
|
|
3041
3171
|
.map((e) => e.descriptor);
|
|
3042
3172
|
}
|
|
3043
|
-
/** Server id owning a registered channel (by descriptor id), or null. */
|
|
3044
|
-
getChannelServerId(channelId) {
|
|
3045
|
-
return this.findChannelEntry(channelId)?.serverId ?? null;
|
|
3046
|
-
}
|
|
3047
3173
|
/**
|
|
3048
3174
|
* The server that declared this channel inbound-only (isInboundOnly), or
|
|
3049
|
-
* undefined when it isn't one. The notices name that
|
|
3050
|
-
*
|
|
3051
|
-
* its id alone (findChannelEntry).
|
|
3175
|
+
* undefined when it isn't one, or isn't registered. The notices name that
|
|
3176
|
+
* server.
|
|
3052
3177
|
*/
|
|
3053
|
-
inboundOnlyServer(
|
|
3054
|
-
const entry =
|
|
3178
|
+
inboundOnlyServer(ref) {
|
|
3179
|
+
const entry = this.entryAt(ref);
|
|
3055
3180
|
return entry && isInboundOnly(entry) ? entry.serverId : undefined;
|
|
3056
3181
|
}
|
|
3182
|
+
/** A channel's entry, by its server and id: the map is keyed by destinationKey. */
|
|
3183
|
+
entryAt(ref) {
|
|
3184
|
+
return this.channels.get(destinationKey(ref));
|
|
3185
|
+
}
|
|
3057
3186
|
/**
|
|
3058
|
-
*
|
|
3059
|
-
*
|
|
3187
|
+
* The registered channels a name given as a string can mean (a tool's
|
|
3188
|
+
* channelId, a `>>` target, a host's call): those whose id is the name and
|
|
3189
|
+
* those whose server-qualified name (qualifiedChannelName) is the name,
|
|
3190
|
+
* taken together. The qualified form is matched against the registered
|
|
3191
|
+
* pairs, never split at a colon, since ids hold colons (discord-mcpl's are
|
|
3192
|
+
* `discord:<guild>:<channel>`). So a name that two entries answer to, in
|
|
3193
|
+
* either form, means both of them, and a caller that has to pick one
|
|
3194
|
+
* refuses it with both as candidates rather than taking the first.
|
|
3060
3195
|
*/
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3196
|
+
entriesNamed(name) {
|
|
3197
|
+
return [...this.channels.values()].filter((e) => e.descriptor.id === name || qualifiedChannelName(refOf(e)) === name);
|
|
3198
|
+
}
|
|
3199
|
+
/** The channels a name given as a string means (entriesNamed), by ref:
|
|
3200
|
+
* for a caller that has to settle a host's or a tool's name itself. */
|
|
3201
|
+
channelsNamed(name) {
|
|
3202
|
+
return this.entriesNamed(name).map(refOf);
|
|
3203
|
+
}
|
|
3204
|
+
/**
|
|
3205
|
+
* The name to show an agent for a channel: one that picks it alone, which
|
|
3206
|
+
* a channel tool's channelId takes back, and a `>>` target too where the
|
|
3207
|
+
* name is one (isProseTargetToken: a single token other than `skip_reply`,
|
|
3208
|
+
* which a `>>` line reads as private speech); proseTargetFor gives the
|
|
3209
|
+
* target to write. A registered
|
|
3210
|
+
* channel's is nameFor's: its id, or, where another server registers the
|
|
3211
|
+
* same id, its server-qualified name. A channel no string picks alone, as
|
|
3212
|
+
* where qualified names collide too, is shown by its id and its server, as
|
|
3213
|
+
* the refusals show it (channelOnServer), which no input takes for a name.
|
|
3214
|
+
* A channel no longer registered is shown by its id, or by its qualified
|
|
3215
|
+
* name, only where no registered channel takes that name, as a channel
|
|
3216
|
+
* tool reads it or a `>>` target does (by label or raw id too), so the
|
|
3217
|
+
* name can't send anything to another channel; else by its id and server.
|
|
3218
|
+
*/
|
|
3219
|
+
nameShown(ref) {
|
|
3220
|
+
const entry = this.entryAt(ref);
|
|
3221
|
+
if (entry)
|
|
3222
|
+
return this.nameFor(entry) ?? channelOnServer(ref);
|
|
3223
|
+
for (const name of [ref.channelId, qualifiedChannelName(ref)]) {
|
|
3224
|
+
if (this.entriesNamed(name).length === 0 && !('channelId' in this.resolveProseTarget(name)))
|
|
3225
|
+
return name;
|
|
3226
|
+
}
|
|
3227
|
+
return channelOnServer(ref);
|
|
3228
|
+
}
|
|
3229
|
+
/**
|
|
3230
|
+
* A tool's refusal of a tuned-out channel, whose decision is the tune-out:
|
|
3231
|
+
* its cancel is what ends it (tuneOutEndText), and a decision recorded over
|
|
3232
|
+
* it would end it without that cancel, so the backlog it diverted would
|
|
3233
|
+
* never reach the agent.
|
|
3234
|
+
*/
|
|
3235
|
+
tunedOutRefusal(tool, entry) {
|
|
3236
|
+
return {
|
|
3237
|
+
success: false,
|
|
3238
|
+
isError: true,
|
|
3239
|
+
error: `Channel ${this.quotedShown(refOf(entry))} is tuned out: its messages go to the subconscious, and ${tool} ` +
|
|
3240
|
+
`doesn't change that. ${this.tuneOutEndText(entry, tool)}`,
|
|
3241
|
+
data: { refusal: 'tuned-out', channelId: entry.descriptor.id, serverId: entry.serverId },
|
|
3242
|
+
};
|
|
3243
|
+
}
|
|
3244
|
+
/**
|
|
3245
|
+
* How a tuned-out channel's tune-out ends, for the texts that tell an agent
|
|
3246
|
+
* channel_open doesn't change it: a tune_out cancel, by the name that
|
|
3247
|
+
* picks the channel (nameFor), or, where no name picks it alone, its own
|
|
3248
|
+
* end, since tune_out takes a name.
|
|
3249
|
+
* For channel_close, which refuses a tuned-out channel too, it adds that
|
|
3250
|
+
* the close comes after that.
|
|
3251
|
+
*/
|
|
3252
|
+
/** How this channel's tune-out ends, as tune_out's own refusals say it
|
|
3253
|
+
* (tuneOutEndText), or undefined for a channel no longer registered. */
|
|
3254
|
+
tuneOutEnd(ref) {
|
|
3255
|
+
const entry = this.entryAt(ref);
|
|
3256
|
+
return entry ? this.tuneOutEndText(entry) : undefined;
|
|
3257
|
+
}
|
|
3258
|
+
tuneOutEndText(entry, tool) {
|
|
3259
|
+
const close = tool === 'channel_close';
|
|
3260
|
+
return this.nameFor(entry) !== undefined
|
|
3261
|
+
? `Call tune_out with channelId ${this.quotedShown(refOf(entry))} and mode "cancel" for it to resume normal ` +
|
|
3262
|
+
`attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}`
|
|
3263
|
+
: 'No channelId names it alone, so tune_out can\'t cancel it: its tune-out ends by itself when its duration ' +
|
|
3264
|
+
'runs out, or at the first wake past its wake budget, and its backlog is delivered then.' +
|
|
3265
|
+
(close ? ' Once it has ended, channel_close can close it.' : '');
|
|
3266
|
+
}
|
|
3267
|
+
/**
|
|
3268
|
+
* A channel's name as nameShown gives it, quoted for a sentence: the name
|
|
3269
|
+
* in quotes, or the id-and-server form, whose parts are quoted already.
|
|
3270
|
+
*/
|
|
3271
|
+
quotedShown(ref) {
|
|
3272
|
+
const shown = this.nameShown(ref);
|
|
3273
|
+
return shown === channelOnServer(ref) ? shown : JSON.stringify(shown);
|
|
3274
|
+
}
|
|
3275
|
+
/**
|
|
3276
|
+
* The string that names exactly this entry by entriesNamed: its id when no
|
|
3277
|
+
* other entry answers to that (the id is unique), else its server-qualified
|
|
3278
|
+
* name when no other entry answers to that, else undefined. The last can
|
|
3279
|
+
* happen only where qualified names collide, as server `a`'s channel
|
|
3280
|
+
* `b:room` and server `a:b`'s channel `room` do; such a channel is named
|
|
3281
|
+
* only by its server and id apart.
|
|
3282
|
+
*/
|
|
3283
|
+
nameFor(entry) {
|
|
3284
|
+
for (const name of [entry.descriptor.id, qualifiedChannelName(refOf(entry))]) {
|
|
3285
|
+
const named = this.entriesNamed(name);
|
|
3286
|
+
if (named.length === 1 && named[0] === entry)
|
|
3287
|
+
return name;
|
|
3066
3288
|
}
|
|
3067
3289
|
return undefined;
|
|
3068
3290
|
}
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3291
|
+
/**
|
|
3292
|
+
* The refusal for a name that more than one registered channel answers
|
|
3293
|
+
* to (entriesNamed). Where each one has a name that picks it alone
|
|
3294
|
+
* (nameFor), as two servers' channels with one id do, it lists those.
|
|
3295
|
+
* Where one has none, the name reads two ways, as server `a`'s `b:room`
|
|
3296
|
+
* and server `a:b`'s `room` both answer to `a:b:room`, and the refusal
|
|
3297
|
+
* gives a line per channel, each with its own route, or, where the tool
|
|
3298
|
+
* has none to it, saying that no channelId reaches it alone.
|
|
3299
|
+
* `takesServerId` says whether the tool also takes a serverId to pick
|
|
3300
|
+
* with; `nothing` says what didn't happen, "nothing ran" unless given.
|
|
3301
|
+
*/
|
|
3302
|
+
sharedNameRefusal(name, entries, opts) {
|
|
3303
|
+
const nothing = opts.nothing ?? 'nothing ran';
|
|
3304
|
+
const names = entries.map((e) => this.nameFor(e));
|
|
3305
|
+
if (names.every((n) => n !== undefined)) {
|
|
3306
|
+
const listed = names.map((n) => JSON.stringify(n)).join(', ');
|
|
3307
|
+
const servers = [...new Set(entries.map((e) => JSON.stringify(e.serverId)))];
|
|
3308
|
+
// One server's two channels can answer to one name too, as `s`'s
|
|
3309
|
+
// `s:room` and `room` both do to `s:room`; its server picks neither.
|
|
3310
|
+
if (servers.length === 1) {
|
|
3311
|
+
return `Channel ${JSON.stringify(name)} could mean more than one channel on server ${servers[0]} ` +
|
|
3312
|
+
`(${listed}), so ${nothing}. Call again with channelId set to one of them.`;
|
|
3081
3313
|
}
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3314
|
+
// A serverId goes with the channel's own id, so it's offered only
|
|
3315
|
+
// where the name is every candidate's own id.
|
|
3316
|
+
const byServer = opts.takesServerId && entries.every((e) => e.descriptor.id === name)
|
|
3317
|
+
? `, or with serverId set to ${servers.join(' or ')}`
|
|
3318
|
+
: '';
|
|
3319
|
+
return `Channel ${JSON.stringify(name)} is on more than one server (${listed}), so ${nothing}.` +
|
|
3320
|
+
` Call again with channelId set to one of them${byServer}.`;
|
|
3321
|
+
}
|
|
3322
|
+
const lines = entries.map((e, i) => {
|
|
3323
|
+
const where = channelOnServer(refOf(e));
|
|
3324
|
+
const own = names[i];
|
|
3325
|
+
if (own === undefined && !opts.takesServerId)
|
|
3326
|
+
return `- ${where}: no channelId reaches it alone.`;
|
|
3327
|
+
return own !== undefined
|
|
3328
|
+
? `- ${where}: call again with channelId ${JSON.stringify(own)}.`
|
|
3329
|
+
: `- ${where}: call again with channelId ${JSON.stringify(e.descriptor.id)} and serverId ${JSON.stringify(e.serverId)}.`;
|
|
3330
|
+
});
|
|
3331
|
+
const count = entries.length === 2 ? 'either of two channels' : `any of ${entries.length} channels`;
|
|
3332
|
+
return `Channel ${JSON.stringify(name)} could mean ${count}, so ${nothing}:\n${lines.join('\n')}`;
|
|
3333
|
+
}
|
|
3334
|
+
/**
|
|
3335
|
+
* The refusal for a name more than one registered channel answers to, for
|
|
3336
|
+
* a tool outside the registry whose schema has no serverId (tune_out, the
|
|
3337
|
+
* subconscious's cancel_tuneout and deliver_summary), or undefined when the
|
|
3338
|
+
* name means one channel or none. `nothing` says what didn't happen,
|
|
3339
|
+
* "nothing ran" unless given.
|
|
3340
|
+
*/
|
|
3341
|
+
sharedNameRefusalFor(name, nothing) {
|
|
3342
|
+
const entries = this.entriesNamed(name);
|
|
3343
|
+
return entries.length > 1 ? this.sharedNameRefusal(name, entries, { takesServerId: false, nothing }) : undefined;
|
|
3344
|
+
}
|
|
3345
|
+
/**
|
|
3346
|
+
* Where a channel id given with a server that doesn't have it is, for that
|
|
3347
|
+
* refusal: the servers that register the id, each a serverId to call again
|
|
3348
|
+
* with, and leaving serverId out only where the id alone then names one
|
|
3349
|
+
* channel (entriesNamed), since another channel's qualified name can be
|
|
3350
|
+
* the same string. For a publish, whose schema has no serverId
|
|
3351
|
+
* (`takesServerId` false: a channel_publish given one the agent added, or
|
|
3352
|
+
* a host's publishForAgent given a ref), where the id is, by the names its
|
|
3353
|
+
* channelId takes (nameFor), and no advice to post there, since each is
|
|
3354
|
+
* another destination, perhaps another community's.
|
|
3355
|
+
*/
|
|
3356
|
+
elsewhereText(channelId, takesServerId = true) {
|
|
3357
|
+
const elsewhere = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
|
|
3358
|
+
const named = this.entriesNamed(channelId);
|
|
3359
|
+
const alone = named.length === 1;
|
|
3360
|
+
if (elsewhere.length === 0) {
|
|
3361
|
+
// Such as a server-qualified name given with serverId too: alone, it
|
|
3362
|
+
// names its channel, which may be another server's than the one given,
|
|
3363
|
+
// so the text says whose.
|
|
3364
|
+
if (!alone)
|
|
3365
|
+
return 'channel_list shows the channels and their servers.';
|
|
3366
|
+
const one = named[0];
|
|
3367
|
+
const names = `${JSON.stringify(channelId)} alone names channel ${JSON.stringify(one.descriptor.id)} on server `
|
|
3368
|
+
+ JSON.stringify(one.serverId);
|
|
3369
|
+
return takesServerId ? `${names}: for that one, call again with serverId left out.` : `${names}.`;
|
|
3370
|
+
}
|
|
3371
|
+
if (!takesServerId) {
|
|
3372
|
+
// Each is another destination, perhaps another community's, so the
|
|
3373
|
+
// text names where the id is, each place by the name its channelId
|
|
3374
|
+
// takes, and leaves the choice to the caller. A place no channelId
|
|
3375
|
+
// reaches alone says so.
|
|
3376
|
+
const places = elsewhere.map((e) => {
|
|
3377
|
+
const at = `on server ${JSON.stringify(e.serverId)}`;
|
|
3378
|
+
const name = this.nameFor(e);
|
|
3379
|
+
return name === undefined ? `${at} (no channelId reaches it alone)` : `${at} as ${JSON.stringify(name)}`;
|
|
3380
|
+
});
|
|
3381
|
+
return `It's ${places.length === 1 ? places[0] : `${places.slice(0, -1).join(', ')} and ${places.at(-1)}`}.`;
|
|
3093
3382
|
}
|
|
3094
|
-
|
|
3383
|
+
const servers = elsewhere.map((e) => JSON.stringify(e.serverId));
|
|
3384
|
+
const leaveOut = alone ? ', or leave serverId out' : '';
|
|
3385
|
+
return servers.length === 1
|
|
3386
|
+
? `It's on server ${servers[0]}: call again with serverId ${servers[0]}${leaveOut}.`
|
|
3387
|
+
: `It's on servers ${servers.join(', ')}: call again with serverId set to one of them.`;
|
|
3095
3388
|
}
|
|
3096
3389
|
/**
|
|
3097
|
-
*
|
|
3390
|
+
* The channel a channel tool names: the channel with id `channelId` on
|
|
3391
|
+
* `serverId` when the call gives a serverId, the exact pair, as every
|
|
3392
|
+
* selector that gives a server reads it; otherwise the one channel its
|
|
3393
|
+
* name means (entriesNamed), which may be server-qualified. A name more
|
|
3394
|
+
* than one channel answers to is refused with them as candidates, never
|
|
3395
|
+
* resolved to the first. Its callers are the tools that take a serverId
|
|
3396
|
+
* (channel_open, channel_close, channel_decline), so the refusal offers
|
|
3397
|
+
* one. `own` is the calling turn's own channels, for an agent's call made
|
|
3398
|
+
* inside it (ChannelToolOrigin.own).
|
|
3098
3399
|
*/
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3400
|
+
resolveToolChannelEntry(channelId, serverId, own) {
|
|
3401
|
+
// Inside the calling turn, a name one of the turn's own channels answers
|
|
3402
|
+
// to by its id means that channel, as a `>>` target does (ownChannelNamed):
|
|
3403
|
+
// once its server no longer registers it, the call is refused, with no
|
|
3404
|
+
// route to another server's channel with the id.
|
|
3405
|
+
const mine = serverId ? undefined : this.ownChannelNamed(channelId, own);
|
|
3406
|
+
if (mine && 'neither' in mine)
|
|
3407
|
+
return { error: this.ownNeitherRefusal(channelId, mine.neither, 'ran') };
|
|
3408
|
+
if (mine) {
|
|
3409
|
+
const entry = this.entryAt(mine.channel);
|
|
3410
|
+
return entry ? { entry } : { error: this.ownChannelGoneRefusal(channelId, mine.channel, 'other') };
|
|
3411
|
+
}
|
|
3412
|
+
if (serverId) {
|
|
3413
|
+
const entry = this.entryAt({ serverId, channelId });
|
|
3414
|
+
if (entry)
|
|
3415
|
+
return { entry };
|
|
3416
|
+
return { error: `No channel "${channelId}" is on server "${serverId}", so nothing ran. ${this.elsewhereText(channelId)}` };
|
|
3417
|
+
}
|
|
3418
|
+
const matches = this.entriesNamed(channelId);
|
|
3419
|
+
if (matches.length === 0) {
|
|
3420
|
+
return { error: `No channel has the id "${channelId}", so nothing ran. channel_list shows the channel ids.` };
|
|
3104
3421
|
}
|
|
3105
|
-
|
|
3422
|
+
if (matches.length > 1)
|
|
3423
|
+
return { error: this.sharedNameRefusal(channelId, matches, { takesServerId: true }) };
|
|
3424
|
+
return { entry: matches[0] };
|
|
3106
3425
|
}
|
|
3107
3426
|
// ==========================================================================
|
|
3108
3427
|
// Private: Tool Handlers
|
|
3109
3428
|
// ==========================================================================
|
|
3110
3429
|
handleToolList() {
|
|
3111
3430
|
const allChannels = [];
|
|
3431
|
+
// How many registered channels answer to each id and server-qualified
|
|
3432
|
+
// id, in one pass, so each entry's name is nameFor's (the one of those
|
|
3433
|
+
// that picks it alone) without a scan of every channel per entry.
|
|
3434
|
+
const answering = new Map();
|
|
3435
|
+
for (const e of this.channels.values()) {
|
|
3436
|
+
for (const n of [e.descriptor.id, qualifiedChannelName(refOf(e))])
|
|
3437
|
+
answering.set(n, (answering.get(n) ?? 0) + 1);
|
|
3438
|
+
}
|
|
3112
3439
|
for (const entry of this.channels.values()) {
|
|
3440
|
+
const name = [entry.descriptor.id, qualifiedChannelName(refOf(entry))].find((n) => answering.get(n) === 1);
|
|
3113
3441
|
allChannels.push({
|
|
3114
3442
|
id: entry.descriptor.id,
|
|
3443
|
+
...(name !== undefined && name !== entry.descriptor.id ? { name } : {}),
|
|
3115
3444
|
type: entry.descriptor.type,
|
|
3116
3445
|
label: entry.descriptor.label,
|
|
3117
3446
|
direction: entry.descriptor.direction,
|
|
@@ -3221,7 +3550,7 @@ export class ChannelRegistry {
|
|
|
3221
3550
|
// and lifted nothing. The lift is ensureInitialDesiredState's own,
|
|
3222
3551
|
// which leaves every real decision alone. One that can't be recorded
|
|
3223
3552
|
// leaves the default, closed.
|
|
3224
|
-
const current = this.
|
|
3553
|
+
const current = this.entryAt(refOf(entry));
|
|
3225
3554
|
let selectedAgain;
|
|
3226
3555
|
if (current) {
|
|
3227
3556
|
try {
|
|
@@ -3286,7 +3615,10 @@ export class ChannelRegistry {
|
|
|
3286
3615
|
* docs/explicit-prose-routing.md) to a registered channel.
|
|
3287
3616
|
*
|
|
3288
3617
|
* Accepted spellings, tried in order:
|
|
3289
|
-
* 1. exact descriptor id (`discord:guild:123
|
|
3618
|
+
* 1. exact descriptor id (`discord:guild:123`), or its server-qualified
|
|
3619
|
+
* name (`a:discord:guild:123`), as entriesNamed matches them; a name
|
|
3620
|
+
* two channels answer to, such as an id two servers register, is
|
|
3621
|
+
* refused with both as candidates
|
|
3290
3622
|
* 2. exact raw server-internal id (`address.channelId`)
|
|
3291
3623
|
* 3. `#label` / bare label — case-insensitive match on descriptor label
|
|
3292
3624
|
* with any leading '#' stripped from both sides
|
|
@@ -3301,39 +3633,170 @@ export class ChannelRegistry {
|
|
|
3301
3633
|
* names the channel as `refusedChannelId`, so a caller can tell a refused
|
|
3302
3634
|
* channel from a name that resolved to nothing. Without it, as for reading
|
|
3303
3635
|
* a channel's history, any registered channel resolves.
|
|
3636
|
+
*
|
|
3637
|
+
* `own` is the turn's own channels, for a target its speech names: a name
|
|
3638
|
+
* one of them answers to by its id means that channel, on its server,
|
|
3639
|
+
* before any other spelling (ownChannelNamed).
|
|
3304
3640
|
*/
|
|
3305
3641
|
resolveProseTarget(spec, opts) {
|
|
3306
|
-
const
|
|
3642
|
+
const own = this.ownChannelNamed(spec.trim(), opts?.own);
|
|
3643
|
+
let resolved;
|
|
3644
|
+
if (own === undefined) {
|
|
3645
|
+
resolved = this.resolveTargetByName(spec);
|
|
3646
|
+
}
|
|
3647
|
+
else if ('neither' in own) {
|
|
3648
|
+
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) };
|
|
3649
|
+
}
|
|
3650
|
+
else {
|
|
3651
|
+
const entry = this.entryAt(own.channel);
|
|
3652
|
+
resolved = entry
|
|
3653
|
+
? { channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label }
|
|
3654
|
+
: { error: `${this.ownChannelGoneText(spec.trim(), own.channel)}, and nothing can be sent there until that server registers it again` };
|
|
3655
|
+
}
|
|
3307
3656
|
if (!opts?.forSpeech || 'error' in resolved)
|
|
3308
3657
|
return resolved;
|
|
3309
|
-
const entry = this.
|
|
3658
|
+
const entry = this.entryAt(resolved);
|
|
3310
3659
|
return entry && isInboundOnly(entry)
|
|
3311
|
-
? { error: `${resolved
|
|
3660
|
+
? { error: `${this.nameShown(resolved)} is ${inboundOnlyReason(entry)}`, refusedChannelId: resolved.channelId }
|
|
3312
3661
|
: resolved;
|
|
3313
3662
|
}
|
|
3663
|
+
/**
|
|
3664
|
+
* The turn's own channel a name means: where the name is the id of one of
|
|
3665
|
+
* the turn's own channels, a channel that has been its trigger or its
|
|
3666
|
+
* locus, that channel, on its server, whether or not that server still
|
|
3667
|
+
* registers it. A message's
|
|
3668
|
+
* reply goes where the message came from, so another server that
|
|
3669
|
+
* registers the id, or takes it once the message's server withdrew it,
|
|
3670
|
+
* isn't reached through the name. Where the turn's channels with that id
|
|
3671
|
+
* are on more than one server, the name means neither. Undefined where no
|
|
3672
|
+
* channel of the turn's has that id.
|
|
3673
|
+
*/
|
|
3674
|
+
ownChannelNamed(name, own) {
|
|
3675
|
+
const named = [];
|
|
3676
|
+
for (const c of own ?? []) {
|
|
3677
|
+
if (c.channelId === name && !named.some((n) => sameChannel(n, c)))
|
|
3678
|
+
named.push(c);
|
|
3679
|
+
}
|
|
3680
|
+
if (named.length === 0)
|
|
3681
|
+
return undefined;
|
|
3682
|
+
return named.length === 1 ? { channel: named[0] } : { neither: named };
|
|
3683
|
+
}
|
|
3684
|
+
/** Why a name the turn's own channel answers to (ownChannelNamed) reaches
|
|
3685
|
+
* nothing once that channel's server no longer registers it. It offers no
|
|
3686
|
+
* other channel with the id, since that's another server's. */
|
|
3687
|
+
ownChannelGoneText(name, channel) {
|
|
3688
|
+
return `"${name}" is this turn's channel on server "${channel.serverId}", which no longer registers it`;
|
|
3689
|
+
}
|
|
3690
|
+
/**
|
|
3691
|
+
* A name the calling turn's own channels answer to by their id, for a tool
|
|
3692
|
+
* outside the registry that takes a channel's name (tune_out, and the
|
|
3693
|
+
* subconscious's cancel_tuneout, speak_in_channel and deliver_summary),
|
|
3694
|
+
* read as the registry's channel tools read it: the turn's channel, or a
|
|
3695
|
+
* refusal where its server no longer registers it, or where the name is
|
|
3696
|
+
* the id of the turn's channels on more than one server. Undefined where
|
|
3697
|
+
* none of the turn's channels has that id. `tool` words the refusal for
|
|
3698
|
+
* what the tool would have done: posted (speak_in_channel), delivered
|
|
3699
|
+
* (deliver_summary) or run (the others).
|
|
3700
|
+
*/
|
|
3701
|
+
ownChannelFor(name, own, tool = 'other') {
|
|
3702
|
+
const mine = this.ownChannelNamed(name, own);
|
|
3703
|
+
if (!mine)
|
|
3704
|
+
return undefined;
|
|
3705
|
+
if ('neither' in mine) {
|
|
3706
|
+
const done = tool === 'publish' ? 'was posted' : tool === 'deliver' ? 'was delivered' : 'ran';
|
|
3707
|
+
return { error: this.ownNeitherRefusal(name, mine.neither, done) };
|
|
3708
|
+
}
|
|
3709
|
+
return this.entryAt(mine.channel) ? { channel: mine.channel } : { error: this.ownChannelGoneRefusal(name, mine.channel, tool) };
|
|
3710
|
+
}
|
|
3711
|
+
/** A refusal of a name the turn's own channel answers to, once its server
|
|
3712
|
+
* no longer registers it: nothing was posted (channel_publish,
|
|
3713
|
+
* speak_in_channel), delivered (deliver_summary) or ran (the other channel
|
|
3714
|
+
* tools, tune_out, cancel_tuneout), and nothing can be until it does
|
|
3715
|
+
* again. */
|
|
3716
|
+
ownChannelGoneRefusal(name, channel, tool) {
|
|
3717
|
+
const outcome = tool === 'publish' ? 'nothing was posted, and nothing can be'
|
|
3718
|
+
: tool === 'deliver' ? 'nothing was delivered, and nothing can be' : 'nothing ran, and nothing can run on it';
|
|
3719
|
+
return `${this.ownChannelGoneText(name, channel)}, so ${outcome} until that server registers it again.`;
|
|
3720
|
+
}
|
|
3721
|
+
/**
|
|
3722
|
+
* The turn's channels a name means neither of (ownChannelNamed), each by a
|
|
3723
|
+
* string that reaches it outside the turn's rule: its server-qualified
|
|
3724
|
+
* name, where that picks it alone, since its id is the name refused. One
|
|
3725
|
+
* no string picks alone, and one its server no longer registers, is named
|
|
3726
|
+
* by its id and server, saying so; `reaches` says which a name reaches.
|
|
3727
|
+
*/
|
|
3728
|
+
ownCandidates(channels) {
|
|
3729
|
+
return channels.map((c) => {
|
|
3730
|
+
const entry = this.entryAt(c);
|
|
3731
|
+
if (!entry)
|
|
3732
|
+
return { text: `${channelOnServer(c)} (its server no longer registers it)`, reaches: false };
|
|
3733
|
+
const qualified = qualifiedChannelName(c);
|
|
3734
|
+
const picked = this.entriesNamed(qualified);
|
|
3735
|
+
return picked.length === 1 && picked[0] === entry
|
|
3736
|
+
? { text: qualified, reaches: true }
|
|
3737
|
+
: { text: `${channelOnServer(c)} (no channelId reaches it alone)`, reaches: false };
|
|
3738
|
+
});
|
|
3739
|
+
}
|
|
3740
|
+
/** A tool's refusal of a name the turn's channels on more than one server
|
|
3741
|
+
* answer to by their id (ownChannelNamed's neither). */
|
|
3742
|
+
ownNeitherRefusal(name, channels, done) {
|
|
3743
|
+
const candidates = this.ownCandidates(channels);
|
|
3744
|
+
const named = candidates.filter((c) => c.reaches).map((c) => JSON.stringify(c.text));
|
|
3745
|
+
const pick = named.length === candidates.length ? ' Call again with channelId set to one of them.'
|
|
3746
|
+
: named.length === 1 ? ` Call again with channelId ${named[0]}.`
|
|
3747
|
+
: named.length > 1 ? ` Call again with channelId set to one of ${named.join(', ')}.` : '';
|
|
3748
|
+
const listed = candidates.map((c) => (c.reaches ? JSON.stringify(c.text) : c.text)).join(', ');
|
|
3749
|
+
return `"${name}" is the id of this turn's channels on more than one server (${listed}), so nothing ${done}.${pick}`;
|
|
3750
|
+
}
|
|
3314
3751
|
resolveTargetByName(spec) {
|
|
3315
3752
|
const entries = [...this.channels.values()];
|
|
3316
3753
|
const trimmed = spec.trim();
|
|
3317
3754
|
if (!trimmed)
|
|
3318
3755
|
return { error: 'empty target' };
|
|
3319
|
-
const
|
|
3756
|
+
const hit = (e) => ({ channelId: e.descriptor.id, serverId: e.serverId, label: e.descriptor.label });
|
|
3757
|
+
// A candidate is named by the token that picks it alone (nameFor), so
|
|
3758
|
+
// two servers' channels with one id or one label can be told apart, or,
|
|
3759
|
+
// where no string picks it alone, by its id and its server.
|
|
3760
|
+
const token = (e) => this.nameFor(e) ?? channelOnServer(refOf(e));
|
|
3761
|
+
// A DM is shown by its label, the person, where that label, given as a
|
|
3762
|
+
// target, picks that DM. It doesn't where another channel answers to that
|
|
3763
|
+
// label too, as two servers' DMs with one person do (a label is matched
|
|
3764
|
+
// ignoring case, so "DM: Antra" and "DM: antra" both answer), nor where
|
|
3765
|
+
// an id, a raw id or another label outranks it, so a bounce's example or
|
|
3766
|
+
// a resend's destination never names another channel. Those are shown by
|
|
3767
|
+
// their tokens instead. A label read as a mention or an @name would come
|
|
3768
|
+
// back here, so it's shown by its token too.
|
|
3769
|
+
const labelPicks = (e, label) => {
|
|
3770
|
+
if (/^@|^<@!?\d+>$/.test(label.trim()))
|
|
3771
|
+
return false;
|
|
3772
|
+
const r = this.resolveTargetByName(label);
|
|
3773
|
+
return 'channelId' in r && sameChannel(r, refOf(e));
|
|
3774
|
+
};
|
|
3775
|
+
const dmShown = (list) => list.map((e) => {
|
|
3776
|
+
const label = e.descriptor.label ?? e.descriptor.id;
|
|
3777
|
+
return labelPicks(e, label) ? label : token(e);
|
|
3778
|
+
});
|
|
3779
|
+
// A channel id, or its server-qualified name (entriesNamed).
|
|
3780
|
+
const exact = this.entriesNamed(trimmed);
|
|
3320
3781
|
if (exact.length === 1) {
|
|
3321
|
-
return
|
|
3782
|
+
return hit(exact[0]);
|
|
3322
3783
|
}
|
|
3323
3784
|
if (exact.length > 1) {
|
|
3324
3785
|
return {
|
|
3325
|
-
error:
|
|
3326
|
-
|
|
3786
|
+
error: new Set(exact.map((e) => e.serverId)).size > 1
|
|
3787
|
+
? `channel id "${trimmed}" is ambiguous across servers`
|
|
3788
|
+
: `channel id "${trimmed}" names more than one channel on server "${exact[0].serverId}"`,
|
|
3789
|
+
candidates: exact.map(token),
|
|
3327
3790
|
};
|
|
3328
3791
|
}
|
|
3329
3792
|
const raw = entries.filter((e) => e.descriptor.address?.channelId === trimmed);
|
|
3330
3793
|
if (raw.length === 1) {
|
|
3331
|
-
return
|
|
3794
|
+
return hit(raw[0]);
|
|
3332
3795
|
}
|
|
3333
3796
|
if (raw.length > 1) {
|
|
3334
3797
|
return {
|
|
3335
3798
|
error: `raw id "${trimmed}" is ambiguous`,
|
|
3336
|
-
candidates: raw.map(
|
|
3799
|
+
candidates: raw.map(token),
|
|
3337
3800
|
};
|
|
3338
3801
|
}
|
|
3339
3802
|
const norm = normalizeChannelLabel;
|
|
@@ -3360,11 +3823,15 @@ export class ChannelRegistry {
|
|
|
3360
3823
|
const id = mention[1];
|
|
3361
3824
|
const byId = dms.filter((e) => dmMeta(e)?.recipientId === id);
|
|
3362
3825
|
if (byId.length === 1) {
|
|
3363
|
-
return
|
|
3826
|
+
return hit(byId[0]);
|
|
3827
|
+
}
|
|
3828
|
+
if (byId.length > 1) {
|
|
3829
|
+
// One person's DMs on more than one server.
|
|
3830
|
+
return { error: `"${trimmed}" matches several DMs`, candidates: dmShown(byId) };
|
|
3364
3831
|
}
|
|
3365
3832
|
return {
|
|
3366
3833
|
error: `no registered DM matches the mention <@${id}>`,
|
|
3367
|
-
candidates: dms.
|
|
3834
|
+
candidates: dmShown(dms.slice(0, 6)),
|
|
3368
3835
|
};
|
|
3369
3836
|
}
|
|
3370
3837
|
const name = trimmed.slice(1).toLowerCase();
|
|
@@ -3376,27 +3843,27 @@ export class ChannelRegistry {
|
|
|
3376
3843
|
return n.length >= 3 && (n.startsWith(name) || name.startsWith(n) || n.includes(name));
|
|
3377
3844
|
});
|
|
3378
3845
|
if (pool.length === 1) {
|
|
3379
|
-
return
|
|
3846
|
+
return hit(pool[0]);
|
|
3380
3847
|
}
|
|
3381
3848
|
if (pool.length > 1) {
|
|
3382
3849
|
return {
|
|
3383
3850
|
error: `"${trimmed}" matches several DMs`,
|
|
3384
|
-
candidates: pool
|
|
3851
|
+
candidates: dmShown(pool),
|
|
3385
3852
|
};
|
|
3386
3853
|
}
|
|
3387
3854
|
return {
|
|
3388
3855
|
error: `no registered DM found for "${trimmed}" — for someone without a registered DM channel, use the send_dm tool`,
|
|
3389
|
-
...(dms.length ? { candidates: dms.
|
|
3856
|
+
...(dms.length ? { candidates: dmShown(dms.slice(0, 6)) } : {}),
|
|
3390
3857
|
};
|
|
3391
3858
|
}
|
|
3392
3859
|
const byLabel = entries.filter((e) => norm(e.descriptor.label ?? '') === norm(trimmed));
|
|
3393
3860
|
if (byLabel.length === 1) {
|
|
3394
|
-
return
|
|
3861
|
+
return hit(byLabel[0]);
|
|
3395
3862
|
}
|
|
3396
3863
|
if (byLabel.length > 1) {
|
|
3397
3864
|
return {
|
|
3398
3865
|
error: `label "${trimmed}" matches several channels`,
|
|
3399
|
-
candidates: byLabel.map(
|
|
3866
|
+
candidates: byLabel.map(token),
|
|
3400
3867
|
};
|
|
3401
3868
|
}
|
|
3402
3869
|
// Name-segment match: server labels carry a disambiguating suffix
|
|
@@ -3407,12 +3874,12 @@ export class ChannelRegistry {
|
|
|
3407
3874
|
const nameOf = normalizeChannelLabelName;
|
|
3408
3875
|
const byName = entries.filter((e) => nameOf(e.descriptor.label ?? '') === norm(trimmed));
|
|
3409
3876
|
if (byName.length === 1) {
|
|
3410
|
-
return
|
|
3877
|
+
return hit(byName[0]);
|
|
3411
3878
|
}
|
|
3412
3879
|
if (byName.length > 1) {
|
|
3413
3880
|
return {
|
|
3414
3881
|
error: `"${trimmed}" matches several channels`,
|
|
3415
|
-
candidates: byName.map((e) => `${e.descriptor.label} = ${e
|
|
3882
|
+
candidates: byName.map((e) => `${e.descriptor.label} = ${token(e)}`),
|
|
3416
3883
|
};
|
|
3417
3884
|
}
|
|
3418
3885
|
// No match: offer near-candidates (labels containing the name) so the
|
|
@@ -3420,7 +3887,7 @@ export class ChannelRegistry {
|
|
|
3420
3887
|
const near = entries
|
|
3421
3888
|
.filter((e) => norm(e.descriptor.label ?? '').includes(norm(trimmed)))
|
|
3422
3889
|
.slice(0, 5)
|
|
3423
|
-
.map((e) => `${e.descriptor.label} = ${e
|
|
3890
|
+
.map((e) => `${e.descriptor.label} = ${token(e)}`);
|
|
3424
3891
|
return { error: `no channel matches "${trimmed}"`, ...(near.length ? { candidates: near } : {}) };
|
|
3425
3892
|
}
|
|
3426
3893
|
/**
|
|
@@ -3430,18 +3897,19 @@ export class ChannelRegistry {
|
|
|
3430
3897
|
* a space can't be quoted verbatim: `>>#DM: alice` parses as target `#DM:`
|
|
3431
3898
|
* plus body `alice …`, and `>>#fable (antra's server)` delivers
|
|
3432
3899
|
* `(antra's server)` as text. Tried in order: `@name` for a DM, `#label`,
|
|
3433
|
-
* `#name` (label without its server suffix), the
|
|
3900
|
+
* `#name` (label without its server suffix), the name that picks the
|
|
3901
|
+
* channel alone (nameFor): its id, or, where another server registers the
|
|
3902
|
+
* same id, its server-qualified name; and last its server-qualified name,
|
|
3903
|
+
* for a channel whose id is no target (isProseTargetToken), as
|
|
3904
|
+
* `skip_reply` isn't.
|
|
3434
3905
|
*
|
|
3435
|
-
* Undefined when there is no safe token: the channel isn't registered
|
|
3436
|
-
*
|
|
3437
|
-
*
|
|
3438
|
-
* channel by id alone, and ids are unique only within a connection, so a
|
|
3439
|
-
* shared id could route the reply through the wrong server.
|
|
3906
|
+
* Undefined when there is no safe token: the channel isn't registered, or
|
|
3907
|
+
* no candidate is whitespace-free and resolves back to this channel, on
|
|
3908
|
+
* this server.
|
|
3440
3909
|
*/
|
|
3441
|
-
proseTargetFor(
|
|
3442
|
-
const
|
|
3443
|
-
|
|
3444
|
-
if (!entry || sameId.length > 1)
|
|
3910
|
+
proseTargetFor(ref) {
|
|
3911
|
+
const entry = this.entryAt(ref);
|
|
3912
|
+
if (!entry)
|
|
3445
3913
|
return undefined;
|
|
3446
3914
|
const d = entry.descriptor;
|
|
3447
3915
|
const label = d.label ?? '';
|
|
@@ -3452,24 +3920,136 @@ export class ChannelRegistry {
|
|
|
3452
3920
|
const candidates = [
|
|
3453
3921
|
...(isDm && dmName ? [`@${dmName}`] : []),
|
|
3454
3922
|
...(bare ? [`#${bare}`, `#${bare.replace(/\s*\([^)]*\)\s*$/, '')}`] : []),
|
|
3455
|
-
|
|
3923
|
+
...(this.nameFor(entry) !== undefined ? [this.nameFor(entry)] : []),
|
|
3924
|
+
qualifiedChannelName(ref),
|
|
3456
3925
|
];
|
|
3457
3926
|
for (const c of candidates) {
|
|
3458
|
-
if (
|
|
3927
|
+
if (!isProseTargetToken(c) || c === '#')
|
|
3459
3928
|
continue;
|
|
3460
3929
|
const r = this.resolveProseTarget(c, { forSpeech: true });
|
|
3461
|
-
if ('channelId' in r && r
|
|
3930
|
+
if ('channelId' in r && sameChannel(r, ref))
|
|
3462
3931
|
return c;
|
|
3463
3932
|
}
|
|
3464
3933
|
return undefined;
|
|
3465
3934
|
}
|
|
3935
|
+
/** Whether the decision on record is a close the agent or a module made
|
|
3936
|
+
* (isCloseDecision). */
|
|
3937
|
+
closeDecided(ref) {
|
|
3938
|
+
const current = this.desiredStates.get(this.lifecycleKey(ref.serverId, ref.channelId));
|
|
3939
|
+
return current?.state === 'closed' && isCloseDecision(current.source);
|
|
3940
|
+
}
|
|
3941
|
+
/**
|
|
3942
|
+
* Whether the agent's words delivered into this channel open it first, by
|
|
3943
|
+
* publish() or after an explicit send (openIfClosedForSend): one that isn't
|
|
3944
|
+
* open, or whose close stands (closeStands: a close that failed may have
|
|
3945
|
+
* left its transport open), unless speech leaves it as it stands
|
|
3946
|
+
* (speechLeavesAsItStands). Where nobody decided (no record, a pure
|
|
3947
|
+
* default) or a machine closed the channel (subscription-gc,
|
|
3948
|
+
* housekeeping), it opens, as it always did, and so does one decided open
|
|
3949
|
+
* whose transport isn't confirmed. A tuned-out channel whose transport
|
|
3950
|
+
* isn't open is opened for its traffic, and its tune-out stays
|
|
3951
|
+
* (openChannelNow). A server that isn't up isn't one this host can't
|
|
3952
|
+
* direct, though a drop empties its grant until its next exchange, so
|
|
3953
|
+
* speech there is refused as it always was: by the open's failure, which
|
|
3954
|
+
* says the server is connecting or reconnecting, or, where its grant is
|
|
3955
|
+
* empty, by publish()'s grant check before any open.
|
|
3956
|
+
*/
|
|
3957
|
+
speechOpens(ref) {
|
|
3958
|
+
const entry = this.entryAt(ref);
|
|
3959
|
+
return !!entry && (!entry.open || this.closeStands(entry)) && !this.speechLeavesAsItStands(entry);
|
|
3960
|
+
}
|
|
3961
|
+
/** Whether speech leaves this registration as it stands, never opening
|
|
3962
|
+
* it, and words go into it as it is: the decision on record is a close
|
|
3963
|
+
* the agent made itself (channel_close, channel_decline) or a module made
|
|
3964
|
+
* (MODULE_CLOSE_SOURCE), which stands against the agent's own speech
|
|
3965
|
+
* whatever the channel's transport is; or its server is up and this host
|
|
3966
|
+
* doesn't direct its lifecycle (lifecycleWithheld), so nothing opens
|
|
3967
|
+
* there. A wait or an open that failed gives way to such a registration,
|
|
3968
|
+
* as when the agent closed the channel while the words waited, since
|
|
3969
|
+
* there's nothing for the words to wait for (refusalGivesWay). */
|
|
3970
|
+
speechLeavesAsItStands(entry) {
|
|
3971
|
+
if (!entry)
|
|
3972
|
+
return false;
|
|
3973
|
+
return this.closeDecided(refOf(entry)) || this.lifecycleWithheld(entry.serverId);
|
|
3974
|
+
}
|
|
3975
|
+
/** Whether words a wait or an open refused go into the channel after all:
|
|
3976
|
+
* its registration `now` is one speech leaves as it stands
|
|
3977
|
+
* (speechLeavesAsItStands), such as one the agent closed or declined
|
|
3978
|
+
* while the words waited, and the failure isn't convergence that never
|
|
3979
|
+
* established its target (convergeFailure). A registration that went
|
|
3980
|
+
* still fails them, and publish() refuses one the server last answered
|
|
3981
|
+
* for another channel (answeredElsewhere), whenever that happened. */
|
|
3982
|
+
refusalGivesWay(err, now) {
|
|
3983
|
+
return this.speechLeavesAsItStands(now) && !convergeFailure(err);
|
|
3984
|
+
}
|
|
3985
|
+
/** Whether this host doesn't direct the server's channel lifecycle: the
|
|
3986
|
+
* server is up, and its grant lacks channels.lifecycle, so nothing opens
|
|
3987
|
+
* or closes its channels. A server that isn't up has an empty grant from
|
|
3988
|
+
* a drop until its next exchange, which says nothing yet of what it
|
|
3989
|
+
* grants, so it isn't counted. */
|
|
3990
|
+
lifecycleWithheld(serverId) {
|
|
3991
|
+
return !this.directsLifecycle(serverId) && this.serverReach(serverId) === 'up';
|
|
3992
|
+
}
|
|
3993
|
+
/** Whether the agent's words go into this channel and leave it closed: it
|
|
3994
|
+
* isn't open at the transport, and speech leaves it as it stands
|
|
3995
|
+
* (speechLeavesAsItStands: the agent's or a module's close, or a server
|
|
3996
|
+
* that's up without channels.lifecycle). False for a channel open at the
|
|
3997
|
+
* transport, whose traffic still flows, and for one speech opens. */
|
|
3998
|
+
speechLeavesClosed(ref) {
|
|
3999
|
+
const entry = this.entryAt(ref);
|
|
4000
|
+
return !!entry && !entry.open && this.speechLeavesAsItStands(entry);
|
|
4001
|
+
}
|
|
3466
4002
|
/**
|
|
3467
|
-
*
|
|
3468
|
-
*
|
|
3469
|
-
*
|
|
3470
|
-
*
|
|
3471
|
-
*
|
|
3472
|
-
|
|
4003
|
+
* How the agent kept this registered channel closed itself, if it did:
|
|
4004
|
+
* 'closed' for its channel_close, 'declined' for its channel_decline. Its
|
|
4005
|
+
* speech there leaves such a channel as it stands (speechOpens). A
|
|
4006
|
+
* channel no longer registered has nothing to keep closed, whatever its
|
|
4007
|
+
* record says, so it answers undefined.
|
|
4008
|
+
*/
|
|
4009
|
+
closedByAgent(ref) {
|
|
4010
|
+
if (!this.entryAt(ref))
|
|
4011
|
+
return undefined;
|
|
4012
|
+
const current = this.desiredStates.get(this.lifecycleKey(ref.serverId, ref.channelId));
|
|
4013
|
+
if (current?.state !== 'closed')
|
|
4014
|
+
return undefined;
|
|
4015
|
+
if (current.source === 'agent-tool')
|
|
4016
|
+
return 'closed';
|
|
4017
|
+
if (current.source === 'invitation-declined')
|
|
4018
|
+
return 'declined';
|
|
4019
|
+
return undefined;
|
|
4020
|
+
}
|
|
4021
|
+
/** The registered channels an explicit send tool may have delivered into,
|
|
4022
|
+
* from the id the tool reported or was given: a composite MCPL id, or the
|
|
4023
|
+
* raw server-internal id (`address.channelId`), since send tools receive
|
|
4024
|
+
* whatever the agent typed. Matches on `serverId` when it is given,
|
|
4025
|
+
* across every server otherwise. */
|
|
4026
|
+
sendTargets(rawChannelId, serverId) {
|
|
4027
|
+
const matches = [...this.channels.values()].filter((e) => e.descriptor.id === rawChannelId && (!serverId || e.serverId === serverId));
|
|
4028
|
+
if (matches.length > 0)
|
|
4029
|
+
return matches;
|
|
4030
|
+
return [...this.channels.values()].filter((e) => e.descriptor.address?.channelId === rawChannelId &&
|
|
4031
|
+
(!serverId || e.serverId === serverId));
|
|
4032
|
+
}
|
|
4033
|
+
/** The one registered channel an explicit send tool delivered into
|
|
4034
|
+
* (sendTargets), or undefined when none or more than one matches. A
|
|
4035
|
+
* lookup only, for what's said beside the send's answer before it's
|
|
4036
|
+
* queued; openIfClosedForSend decides whether the send opens it. */
|
|
4037
|
+
resolveSendTarget(rawChannelId, serverId) {
|
|
4038
|
+
const matches = this.sendTargets(rawChannelId, serverId);
|
|
4039
|
+
return matches.length === 1 ? refOf(matches[0]) : undefined;
|
|
4040
|
+
}
|
|
4041
|
+
/**
|
|
4042
|
+
* Open a channel because something was DELIVERED into it by an explicit
|
|
4043
|
+
* send tool, so typing indicators, reaction machinery and inbound
|
|
4044
|
+
* forwarding all come alive with the first outbound message, unless
|
|
4045
|
+
* speech leaves it as it stands (speechLeavesAsItStands: the agent closed
|
|
4046
|
+
* or declined it itself, a module closed it, or its server is up and this
|
|
4047
|
+
* host doesn't direct its lifecycle). Such a channel opens nothing, and
|
|
4048
|
+
* answers `stays-closed`, or `already-open` where its transport is open
|
|
4049
|
+
* with no close standing, whether the send found it so or it became so
|
|
4050
|
+
* while the send's wait or open was out.
|
|
4051
|
+
* Accepts composite MCPL ids or raw server-internal ids (sendTargets). An
|
|
4052
|
+
* `open-failed` answer says
|
|
3473
4053
|
* whether the open that failed was one that would have opened a channel
|
|
3474
4054
|
* not selected open (`opening`), as opposed to one reconfirming the
|
|
3475
4055
|
* transport of a channel already selected open or tuned out. It isn't one
|
|
@@ -3482,11 +4062,7 @@ export class ChannelRegistry {
|
|
|
3482
4062
|
* (`selected-again`).
|
|
3483
4063
|
*/
|
|
3484
4064
|
async openIfClosedForSend(rawChannelId, serverId) {
|
|
3485
|
-
|
|
3486
|
-
if (matches.length === 0) {
|
|
3487
|
-
matches = [...this.channels.values()].filter((e) => e.descriptor.address?.channelId === rawChannelId &&
|
|
3488
|
-
(!serverId || e.serverId === serverId));
|
|
3489
|
-
}
|
|
4065
|
+
const matches = this.sendTargets(rawChannelId, serverId);
|
|
3490
4066
|
if (matches.length === 0)
|
|
3491
4067
|
return { status: 'unknown-channel' };
|
|
3492
4068
|
if (matches.length > 1)
|
|
@@ -3499,8 +4075,14 @@ export class ChannelRegistry {
|
|
|
3499
4075
|
}
|
|
3500
4076
|
const desired = this.getDesiredState(entry.serverId, entry.descriptor.id);
|
|
3501
4077
|
if (entry.open && (desired === 'open' || desired === 'tuned-out')) {
|
|
3502
|
-
return { status: 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
|
|
4078
|
+
return { status: 'already-open', channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label };
|
|
3503
4079
|
}
|
|
4080
|
+
// A channel speech leaves as it stands opens nothing. Every other one
|
|
4081
|
+
// is opened, as it always was: one open at the transport with no
|
|
4082
|
+
// decision on record too, so the send's engagement is recorded as an
|
|
4083
|
+
// open decision, which a retarget or a restart carries out.
|
|
4084
|
+
if (this.speechLeavesAsItStands(entry))
|
|
4085
|
+
return this.sendLeavesChannel(entry);
|
|
3504
4086
|
opening = desired !== 'open' && desired !== 'tuned-out';
|
|
3505
4087
|
const { opened } = await this.openChannelNow(entry, 'opened-by-reply');
|
|
3506
4088
|
entry = await this.waitForOpenChannel(entry);
|
|
@@ -3511,12 +4093,23 @@ export class ChannelRegistry {
|
|
|
3511
4093
|
channelId: entry.descriptor.id,
|
|
3512
4094
|
});
|
|
3513
4095
|
}
|
|
3514
|
-
return {
|
|
4096
|
+
return {
|
|
4097
|
+
status: opened ? 'opened' : 'already-open',
|
|
4098
|
+
channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label,
|
|
4099
|
+
};
|
|
3515
4100
|
}
|
|
3516
4101
|
catch (err) {
|
|
4102
|
+
// A channel the send now leaves as it stands, such as one the agent
|
|
4103
|
+
// closed or declined while queued work or the open was out, answers as
|
|
4104
|
+
// one it found so: nothing failed that the send would have done.
|
|
4105
|
+
// Convergence that never established its target is still reported
|
|
4106
|
+
// (refusalGivesWay).
|
|
4107
|
+
const now = this.entryAt({ serverId: entry.serverId, channelId: entry.descriptor.id });
|
|
4108
|
+
if (this.refusalGivesWay(err, now))
|
|
4109
|
+
return this.sendLeavesChannel(now);
|
|
3517
4110
|
// Where a decision made during the open's round-trip replaced it, such
|
|
3518
|
-
// as the agent's
|
|
3519
|
-
//
|
|
4111
|
+
// as the agent's channel_open, the channel is as that decision left it,
|
|
4112
|
+
// and the agent isn't told the open failed.
|
|
3520
4113
|
if (err instanceof ChannelLifecycleError && err.kind === 'overtaken')
|
|
3521
4114
|
opening = false;
|
|
3522
4115
|
this.emitTraceFn({
|
|
@@ -3525,8 +4118,8 @@ export class ChannelRegistry {
|
|
|
3525
4118
|
channelId: entry.descriptor.id,
|
|
3526
4119
|
error: err.message,
|
|
3527
4120
|
});
|
|
3528
|
-
const current = this.
|
|
3529
|
-
return { status: 'open-failed', channelId: entry.descriptor.id, opening,
|
|
4121
|
+
const current = this.entryAt({ serverId: entry.serverId, channelId: entry.descriptor.id });
|
|
4122
|
+
return { status: 'open-failed', channelId: entry.descriptor.id, serverId: entry.serverId, opening,
|
|
3530
4123
|
// Where the channel is selected for opening after the failure, the
|
|
3531
4124
|
// agent is told so and why, as speech's failure says.
|
|
3532
4125
|
...(err instanceof ChannelLifecycleError && err.kind === 'put-back-unrecorded' ? { selectedForOpening: 'unrecorded' } : {}),
|
|
@@ -3534,8 +4127,16 @@ export class ChannelRegistry {
|
|
|
3534
4127
|
...(current ? { label: current.descriptor.label } : {}) };
|
|
3535
4128
|
}
|
|
3536
4129
|
}
|
|
3537
|
-
|
|
3538
|
-
|
|
4130
|
+
/** What a send answers for a channel it leaves as it is
|
|
4131
|
+
* (speechLeavesAsItStands):
|
|
4132
|
+
* `already-open` where its transport is open with no close standing,
|
|
4133
|
+
* `stays-closed` otherwise. */
|
|
4134
|
+
sendLeavesChannel(entry) {
|
|
4135
|
+
const status = entry.open && !this.closeStands(entry) ? 'already-open' : 'stays-closed';
|
|
4136
|
+
return { status, channelId: entry.descriptor.id, serverId: entry.serverId, label: entry.descriptor.label };
|
|
4137
|
+
}
|
|
4138
|
+
async handleToolOpen(input, own) {
|
|
4139
|
+
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, own);
|
|
3539
4140
|
const entry = resolved.entry;
|
|
3540
4141
|
if (!entry) {
|
|
3541
4142
|
return {
|
|
@@ -3544,12 +4145,17 @@ export class ChannelRegistry {
|
|
|
3544
4145
|
isError: true,
|
|
3545
4146
|
};
|
|
3546
4147
|
}
|
|
4148
|
+
// From here, the channel's own id, whichever name the call gave for it
|
|
4149
|
+
// (a server-qualified one included).
|
|
4150
|
+
input = { ...input, channelId: entry.descriptor.id };
|
|
4151
|
+
// The channel as the agent is shown it, and names it back (nameShown).
|
|
4152
|
+
const shown = this.nameShown(refOf(entry));
|
|
3547
4153
|
// A tuned-out channel is transport-open but attention-diverted; a plain
|
|
3548
4154
|
// open would silently discard the epoch (and its pending backlog dump).
|
|
3549
4155
|
// Cancelling is the tune-out coordinator's flow, reached via its own
|
|
3550
4156
|
// tool — refuse with the pointer rather than eat the state.
|
|
3551
4157
|
if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
|
|
3552
|
-
return tunedOutRefusal('channel_open',
|
|
4158
|
+
return this.tunedOutRefusal('channel_open', entry);
|
|
3553
4159
|
}
|
|
3554
4160
|
const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open';
|
|
3555
4161
|
if (entry.open && alreadyDesiredOpen && !input.backscroll && !this.hasPendingLifecycle(entry)) {
|
|
@@ -3563,13 +4169,13 @@ export class ChannelRegistry {
|
|
|
3563
4169
|
catch (err) {
|
|
3564
4170
|
return {
|
|
3565
4171
|
success: false,
|
|
3566
|
-
error: `Channel ${
|
|
4172
|
+
error: `Channel ${shown} is already open, but recording your decision to open it failed: ${err.message}`,
|
|
3567
4173
|
isError: true,
|
|
3568
4174
|
};
|
|
3569
4175
|
}
|
|
3570
4176
|
return {
|
|
3571
4177
|
success: true,
|
|
3572
|
-
data: { channelId: input.channelId, status: 'already open' },
|
|
4178
|
+
data: { channelId: input.channelId, serverId: entry.serverId, status: 'already open' },
|
|
3573
4179
|
};
|
|
3574
4180
|
}
|
|
3575
4181
|
try {
|
|
@@ -3584,6 +4190,7 @@ export class ChannelRegistry {
|
|
|
3584
4190
|
success: true,
|
|
3585
4191
|
data: {
|
|
3586
4192
|
channelId: input.channelId,
|
|
4193
|
+
serverId: entry.serverId,
|
|
3587
4194
|
status: alreadyDesiredOpen ? 'reconciled' : 'opened',
|
|
3588
4195
|
...(result.history ? { history: result.history } : {}),
|
|
3589
4196
|
...(result.historyTruncated ? { historyTruncated: true } : {}),
|
|
@@ -3603,7 +4210,7 @@ export class ChannelRegistry {
|
|
|
3603
4210
|
}
|
|
3604
4211
|
}
|
|
3605
4212
|
async handleToolClose(input, origin) {
|
|
3606
|
-
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
|
|
4213
|
+
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, origin?.kind === 'agent' ? origin.own : undefined);
|
|
3607
4214
|
const entry = resolved.entry;
|
|
3608
4215
|
if (!entry) {
|
|
3609
4216
|
return {
|
|
@@ -3612,13 +4219,19 @@ export class ChannelRegistry {
|
|
|
3612
4219
|
isError: true,
|
|
3613
4220
|
};
|
|
3614
4221
|
}
|
|
4222
|
+
// From here, the channel's own id, whichever name the call gave for it
|
|
4223
|
+
// (a server-qualified one included).
|
|
4224
|
+
input = { ...input, channelId: entry.descriptor.id };
|
|
4225
|
+
// The channel as the agent is shown it, and names it back (nameShown).
|
|
4226
|
+
const shown = this.nameShown(refOf(entry));
|
|
3615
4227
|
// Provenance comes from trusted dispatch, not from self-description:
|
|
3616
4228
|
// only module-origin calls may record a machine source, and only from
|
|
3617
|
-
// the closed vocabulary
|
|
4229
|
+
// the closed vocabulary; without one, a module's close is its own
|
|
4230
|
+
// (MODULE_CLOSE_SOURCE). Agent-origin (or origin-less — the safe
|
|
3618
4231
|
// default for any legacy caller) records 'agent-tool' and has its
|
|
3619
4232
|
// machine fields ignored entirely.
|
|
3620
4233
|
const isModuleOrigin = origin?.kind === 'module';
|
|
3621
|
-
let closeSource = 'agent-tool';
|
|
4234
|
+
let closeSource = isModuleOrigin ? MODULE_CLOSE_SOURCE : 'agent-tool';
|
|
3622
4235
|
if (isModuleOrigin && input.source !== undefined) {
|
|
3623
4236
|
if (!MACHINE_CLOSE_SOURCES.has(input.source)) {
|
|
3624
4237
|
return {
|
|
@@ -3642,13 +4255,13 @@ export class ChannelRegistry {
|
|
|
3642
4255
|
catch (err) {
|
|
3643
4256
|
return {
|
|
3644
4257
|
success: false,
|
|
3645
|
-
error: `Channel ${
|
|
4258
|
+
error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${err.message}`,
|
|
3646
4259
|
isError: true,
|
|
3647
4260
|
};
|
|
3648
4261
|
}
|
|
3649
4262
|
return {
|
|
3650
4263
|
success: true,
|
|
3651
|
-
data: { channelId: input.channelId, status: 'already closed' },
|
|
4264
|
+
data: { channelId: input.channelId, serverId: entry.serverId, status: 'already closed' },
|
|
3652
4265
|
};
|
|
3653
4266
|
}
|
|
3654
4267
|
// Housekeeping must not override stated intent (issue #5: GC closes wore
|
|
@@ -3656,7 +4269,7 @@ export class ChannelRegistry {
|
|
|
3656
4269
|
// close of a channel whose current desired state is an agent/operator
|
|
3657
4270
|
// 'open' is refused — structurally, so the caller can stand down rather
|
|
3658
4271
|
// than retry — unless it certifies an explicit idle lease.
|
|
3659
|
-
if (isModuleOrigin && closeSource
|
|
4272
|
+
if (isModuleOrigin && MACHINE_CLOSE_SOURCES.has(closeSource) && !input.overrideExplicitOpen) {
|
|
3660
4273
|
const current = this.desiredStates.get(this.lifecycleKey(entry.serverId, input.channelId));
|
|
3661
4274
|
// Tuned-out is stated intent too: a GC/housekeeping close would
|
|
3662
4275
|
// silently end the epoch and orphan its backlog.
|
|
@@ -3664,10 +4277,10 @@ export class ChannelRegistry {
|
|
|
3664
4277
|
return {
|
|
3665
4278
|
success: false,
|
|
3666
4279
|
isError: false,
|
|
3667
|
-
error: `Channel ${
|
|
4280
|
+
error: `Channel ${shown} was explicitly opened (source: agent-tool); ` +
|
|
3668
4281
|
`a '${closeSource}' close does not override stated intent. Machine closes ` +
|
|
3669
4282
|
`of explicit opens require an explicit idle lease (overrideExplicitOpen).`,
|
|
3670
|
-
data: { refusal: 'explicit-open', channelId: input.channelId },
|
|
4283
|
+
data: { refusal: 'explicit-open', channelId: input.channelId, serverId: entry.serverId },
|
|
3671
4284
|
};
|
|
3672
4285
|
}
|
|
3673
4286
|
}
|
|
@@ -3679,7 +4292,7 @@ export class ChannelRegistry {
|
|
|
3679
4292
|
// that certified an idle lease, which the guard above lets through, is
|
|
3680
4293
|
// refused too, as a refusal it stands down on rather than an error.
|
|
3681
4294
|
if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
|
|
3682
|
-
const refusal = tunedOutRefusal('channel_close',
|
|
4295
|
+
const refusal = this.tunedOutRefusal('channel_close', entry);
|
|
3683
4296
|
return isModuleOrigin ? { ...refusal, isError: false } : refusal;
|
|
3684
4297
|
}
|
|
3685
4298
|
// A decision the log refused changed nothing, and the answer says so.
|
|
@@ -3689,7 +4302,9 @@ export class ChannelRegistry {
|
|
|
3689
4302
|
catch (err) {
|
|
3690
4303
|
return { success: false, error: this.lifecycleFailureText('close', entry, unrecordedDecision(err)), isError: true };
|
|
3691
4304
|
}
|
|
3692
|
-
|
|
4305
|
+
// This channel's typing, not every channel with its id (stopTyping's
|
|
4306
|
+
// fan-out of a bare name).
|
|
4307
|
+
this.stopTyping(refOf(entry));
|
|
3693
4308
|
// Refused now, the close stays recorded for the server's next
|
|
3694
4309
|
// registration, and the text says so.
|
|
3695
4310
|
if (!this.directsLifecycle(entry.serverId)) {
|
|
@@ -3702,27 +4317,37 @@ export class ChannelRegistry {
|
|
|
3702
4317
|
throw new ChannelLifecycleError(supersededClause('close', now), 'superseded');
|
|
3703
4318
|
return {
|
|
3704
4319
|
success: true,
|
|
3705
|
-
data: { channelId: input.channelId, status: 'closed' },
|
|
4320
|
+
data: { channelId: input.channelId, serverId: entry.serverId, status: 'closed' },
|
|
3706
4321
|
};
|
|
3707
4322
|
}
|
|
3708
4323
|
catch (err) {
|
|
3709
4324
|
return { success: false, error: this.lifecycleFailureText('close', entry, err), isError: true };
|
|
3710
4325
|
}
|
|
3711
4326
|
}
|
|
3712
|
-
async handleToolDecline(input) {
|
|
3713
|
-
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
|
|
4327
|
+
async handleToolDecline(input, own) {
|
|
4328
|
+
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId, own);
|
|
3714
4329
|
const entry = resolved.entry;
|
|
3715
4330
|
if (!entry) {
|
|
3716
4331
|
return { success: false, error: resolved.error, isError: true };
|
|
3717
4332
|
}
|
|
4333
|
+
// From here, the channel's own id, whichever name the call gave for it
|
|
4334
|
+
// (a server-qualified one included).
|
|
4335
|
+
input = { ...input, channelId: entry.descriptor.id };
|
|
4336
|
+
// The channel as the agent is shown it, and names it back (quotedShown).
|
|
4337
|
+
const quoted = this.quotedShown(refOf(entry));
|
|
3718
4338
|
// A tuned-out channel's decision is the tune-out, whatever its transport,
|
|
3719
4339
|
// and a decline recorded over it would end it without its cancel, so the
|
|
3720
4340
|
// backlog it diverted would never reach the agent. Its cancel is what ends
|
|
3721
4341
|
// it, as channel_open's refusal of a tuned-out channel says.
|
|
3722
4342
|
if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
|
|
3723
|
-
return tunedOutRefusal('channel_decline',
|
|
3724
|
-
}
|
|
3725
|
-
|
|
4343
|
+
return this.tunedOutRefusal('channel_decline', entry);
|
|
4344
|
+
}
|
|
4345
|
+
// Refused only for a channel open at the transport. One decided open
|
|
4346
|
+
// whose open failed or is still on its way isn't open there, and the
|
|
4347
|
+
// invitation a message from it brings offers the decline as the way to
|
|
4348
|
+
// reply without opening it. The decline closes it below, through the
|
|
4349
|
+
// lifecycle queue, wherever its transport may be open.
|
|
4350
|
+
if (entry.open) {
|
|
3726
4351
|
// channel_close is the call that closes it: its own answer says when,
|
|
3727
4352
|
// as for a server that isn't up. A server that's up without that grant
|
|
3728
4353
|
// keeps its channels as it chooses, so the text says so, and whom to
|
|
@@ -3731,7 +4356,7 @@ export class ChannelRegistry {
|
|
|
3731
4356
|
? this.lifecycleRefusal(entry.serverId) : undefined;
|
|
3732
4357
|
return {
|
|
3733
4358
|
success: false,
|
|
3734
|
-
error: `Channel
|
|
4359
|
+
error: `Channel ${quoted} is open, so there's nothing to decline` +
|
|
3735
4360
|
(refusal ? `, and ${refusal.message}; if you need it closed, ask whoever runs this host.`
|
|
3736
4361
|
: '. If you need it closed, call channel_close.'),
|
|
3737
4362
|
isError: true,
|
|
@@ -3746,7 +4371,7 @@ export class ChannelRegistry {
|
|
|
3746
4371
|
catch (err) {
|
|
3747
4372
|
return {
|
|
3748
4373
|
success: false,
|
|
3749
|
-
error: `Nothing changed for channel
|
|
4374
|
+
error: `Nothing changed for channel ${quoted}: ${unrecordedDecision(err).message}, so no acknowledgment ` +
|
|
3750
4375
|
'was posted and nothing was sent to the server. Calling channel_decline again tries again; if it fails the same ' +
|
|
3751
4376
|
'way, ask whoever runs this host.',
|
|
3752
4377
|
isError: true,
|
|
@@ -3827,7 +4452,7 @@ export class ChannelRegistry {
|
|
|
3827
4452
|
// below; the decision stands.
|
|
3828
4453
|
let closeFailure;
|
|
3829
4454
|
let closeFailed = false;
|
|
3830
|
-
const current = this.
|
|
4455
|
+
const current = this.entryAt({ serverId: entry.serverId, channelId: input.channelId });
|
|
3831
4456
|
if (current && this.directsLifecycle(entry.serverId) &&
|
|
3832
4457
|
this.getDesiredState(entry.serverId, input.channelId) === 'closed' &&
|
|
3833
4458
|
(current.open || this.unconfirmedChannelTargets.has(current) || this.hasPendingLifecycle(current))) {
|
|
@@ -3852,7 +4477,7 @@ export class ChannelRegistry {
|
|
|
3852
4477
|
// asks it to open or close none of them, so a channel there is confirmed
|
|
3853
4478
|
// closed only by the receipt of a request sent while the server had the
|
|
3854
4479
|
// grant, whether the channel was registered or known only from a push.
|
|
3855
|
-
const after = this.
|
|
4480
|
+
const after = this.entryAt({ serverId: entry.serverId, channelId: input.channelId });
|
|
3856
4481
|
const closed = after !== undefined && !after.open && !this.unconfirmedChannelTargets.has(after) &&
|
|
3857
4482
|
!this.lifecycleOutstanding.has(this.lifecycleKey(entry.serverId, input.channelId));
|
|
3858
4483
|
// `closeError` is the result's sentence for the agent: what became of
|
|
@@ -3965,6 +4590,7 @@ export class ChannelRegistry {
|
|
|
3965
4590
|
success: true,
|
|
3966
4591
|
data: {
|
|
3967
4592
|
channelId: input.channelId,
|
|
4593
|
+
serverId: entry.serverId,
|
|
3968
4594
|
status,
|
|
3969
4595
|
acknowledged,
|
|
3970
4596
|
...(representation ? { representation } : {}),
|
|
@@ -4067,101 +4693,60 @@ export class ChannelRegistry {
|
|
|
4067
4693
|
* inbound-only (#350's rule), not even for a publish delivered there just
|
|
4068
4694
|
* before the declaration. One that started there before it goes on, since
|
|
4069
4695
|
* the declaration doesn't unregister the channel: a later chunk is for a
|
|
4070
|
-
* publish delivered before it.
|
|
4071
|
-
* the one server that registers it, at each call (streamingServerFor), and
|
|
4072
|
-
* keeps no record: a stream sent by destination ends by destination, since
|
|
4073
|
-
* only a destination's `complete` removes its record.
|
|
4696
|
+
* publish delivered before it.
|
|
4074
4697
|
*/
|
|
4075
4698
|
sendOutgoingChunk(to, conversationId, inferenceId, index, delta) {
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
else {
|
|
4081
|
-
const key = destinationKey(to);
|
|
4082
|
-
let started = this.takenStreams.get(inferenceId);
|
|
4083
|
-
const continuing = started?.has(key) ?? false;
|
|
4084
|
-
server = this.streamingServerAt(to, !continuing);
|
|
4085
|
-
if (server && !continuing) {
|
|
4086
|
-
if (!started)
|
|
4087
|
-
this.takenStreams.set(inferenceId, started = new Set());
|
|
4088
|
-
started.add(key);
|
|
4089
|
-
}
|
|
4090
|
-
}
|
|
4699
|
+
const key = destinationKey(to);
|
|
4700
|
+
let started = this.takenStreams.get(inferenceId);
|
|
4701
|
+
const continuing = started?.has(key) ?? false;
|
|
4702
|
+
const server = this.streamingServerAt(to, !continuing);
|
|
4091
4703
|
if (!server)
|
|
4092
4704
|
return;
|
|
4093
|
-
|
|
4705
|
+
if (!continuing) {
|
|
4706
|
+
if (!started)
|
|
4707
|
+
this.takenStreams.set(inferenceId, started = new Set());
|
|
4708
|
+
started.add(key);
|
|
4709
|
+
}
|
|
4094
4710
|
try {
|
|
4095
|
-
server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId, index, delta });
|
|
4711
|
+
server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId: to.channelId, index, delta });
|
|
4096
4712
|
}
|
|
4097
4713
|
catch { /* observer surface — never disturb the turn */ }
|
|
4098
4714
|
}
|
|
4099
4715
|
/**
|
|
4100
4716
|
* Spec 14.3 companion: final moderated content per channel at stream end.
|
|
4101
|
-
*
|
|
4102
|
-
*
|
|
4103
|
-
*
|
|
4104
|
-
*
|
|
4105
|
-
* and reconciles any chunk it missed. A bare channel id is resolved as
|
|
4106
|
-
* before (streamingServerFor).
|
|
4717
|
+
* It goes only where the stream started (sendOutgoingChunk), while that
|
|
4718
|
+
* server still registers that channel, inbound-only or not, and its grant
|
|
4719
|
+
* has `channels.streaming`: once per destination, whose consumer, such as
|
|
4720
|
+
* a TTS utterance, finalizes on it and reconciles any chunk it missed.
|
|
4107
4721
|
*/
|
|
4108
4722
|
sendOutgoingComplete(to, conversationId, inferenceId, text) {
|
|
4109
|
-
|
|
4110
|
-
if (
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
if (!started?.delete(destinationKey(to)))
|
|
4116
|
-
return;
|
|
4117
|
-
if (started.size === 0)
|
|
4118
|
-
this.takenStreams.delete(inferenceId);
|
|
4119
|
-
server = this.streamingServerAt(to, false);
|
|
4120
|
-
}
|
|
4723
|
+
const started = this.takenStreams.get(inferenceId);
|
|
4724
|
+
if (!started?.delete(destinationKey(to)))
|
|
4725
|
+
return;
|
|
4726
|
+
if (started.size === 0)
|
|
4727
|
+
this.takenStreams.delete(inferenceId);
|
|
4728
|
+
const server = this.streamingServerAt(to, false);
|
|
4121
4729
|
if (!server)
|
|
4122
4730
|
return;
|
|
4123
4731
|
try {
|
|
4124
4732
|
server.sendChannelsOutgoingComplete({
|
|
4125
4733
|
inferenceId,
|
|
4126
4734
|
conversationId,
|
|
4127
|
-
channelId:
|
|
4735
|
+
channelId: to.channelId,
|
|
4128
4736
|
content: [{ type: 'text', text }],
|
|
4129
4737
|
});
|
|
4130
4738
|
}
|
|
4131
4739
|
catch { /* observer surface — never disturb the turn */ }
|
|
4132
4740
|
}
|
|
4133
|
-
streamingServerFor(channelId) {
|
|
4134
|
-
// The map is keyed `${serverId}:${channelId}`; stream targets arrive as
|
|
4135
|
-
// bare descriptor ids (what resolveProseTarget returns). Scan like the
|
|
4136
|
-
// resolver does, and fail closed on cross-server ambiguity — the same
|
|
4137
|
-
// never-guess rule that governs delivery.
|
|
4138
|
-
const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
|
|
4139
|
-
if (matches.length !== 1)
|
|
4140
|
-
return null;
|
|
4141
|
-
// Nothing is published into an inbound-only channel, so nothing is
|
|
4142
|
-
// streamed there either.
|
|
4143
|
-
if (isInboundOnly(matches[0]))
|
|
4144
|
-
return null;
|
|
4145
|
-
const server = this.serverRegistry.getServer(matches[0].serverId);
|
|
4146
|
-
// §5.4: the GRANT gates streaming, not the raw advertisement. The old
|
|
4147
|
-
// `capabilities?.channels?.streaming` check was doubly wrong: undefined
|
|
4148
|
-
// for the boolean `channels: true` shape (masking discord-mcpl's latent
|
|
4149
|
-
// double-post, AUDIT-001), and pre-policy it would have sent before the
|
|
4150
|
-
// server was told anything was granted.
|
|
4151
|
-
if (!CapabilityGrant.of(server).has('channels.streaming'))
|
|
4152
|
-
return null;
|
|
4153
|
-
return server;
|
|
4154
|
-
}
|
|
4155
4741
|
/**
|
|
4156
4742
|
* A destination's server, for a stream message: while that server
|
|
4157
|
-
* registers that channel, and its grant has `channels.streaming` (
|
|
4158
|
-
*
|
|
4743
|
+
* registers that channel, and its grant has `channels.streaming` (§5.4:
|
|
4744
|
+
* the grant gates streaming, not the raw advertisement). A stream doesn't
|
|
4745
|
+
* start in an inbound-only channel.
|
|
4159
4746
|
*/
|
|
4160
4747
|
streamingServerAt(to, starting) {
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
const entry = this.channels.get(`${to.serverId}:${to.channelId}`);
|
|
4164
|
-
if (!entry || entry.serverId !== to.serverId)
|
|
4748
|
+
const entry = this.entryAt(to);
|
|
4749
|
+
if (!entry)
|
|
4165
4750
|
return null;
|
|
4166
4751
|
if (starting && isInboundOnly(entry))
|
|
4167
4752
|
return null;
|
|
@@ -4169,11 +4754,16 @@ export class ChannelRegistry {
|
|
|
4169
4754
|
return CapabilityGrant.of(server).has('channels.streaming') ? server : null;
|
|
4170
4755
|
}
|
|
4171
4756
|
/** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel →
|
|
4172
|
-
* process-global default). Public so a multi-segment
|
|
4173
|
-
* ONCE and pin every segment to it via
|
|
4757
|
+
* process-global default), with its server. Public so a multi-segment
|
|
4758
|
+
* caller can snapshot it ONCE and pin every segment to it via
|
|
4759
|
+
* routeSpeech's locus. */
|
|
4174
4760
|
resolveLocus(conversationId) {
|
|
4175
4761
|
const home = this.homeChannelResolver?.(conversationId);
|
|
4176
|
-
|
|
4762
|
+
if (home)
|
|
4763
|
+
return home;
|
|
4764
|
+
// A turn that deliberately has no channel (null) takes no fallback.
|
|
4765
|
+
const active = this.activeChannelResolver?.(conversationId);
|
|
4766
|
+
return active !== undefined ? active : this.globalFallbackFor(conversationId);
|
|
4177
4767
|
}
|
|
4178
4768
|
/** The process-global fallback channel for this conversation, or null when it takes none (takesGlobalFallback). */
|
|
4179
4769
|
globalFallbackFor(conversationId) {
|
|
@@ -4181,9 +4771,10 @@ export class ChannelRegistry {
|
|
|
4181
4771
|
}
|
|
4182
4772
|
/**
|
|
4183
4773
|
* Resolve a publish target to exactly one registered channel: `serverId`
|
|
4184
|
-
* plus `channelId` when the caller has the server, otherwise
|
|
4185
|
-
*
|
|
4186
|
-
* to whichever server registered it
|
|
4774
|
+
* plus `channelId` when the caller has the server, otherwise the one
|
|
4775
|
+
* channel the id, or its server-qualified name, means (entriesNamed). A
|
|
4776
|
+
* shared id is refused, never resolved to whichever server registered it
|
|
4777
|
+
* first.
|
|
4187
4778
|
*/
|
|
4188
4779
|
resolveDestination(target) {
|
|
4189
4780
|
const entry = this.findExactEntry(target);
|
|
@@ -4206,7 +4797,11 @@ export class ChannelRegistry {
|
|
|
4206
4797
|
if (target.serverId !== undefined && (typeof target.serverId !== 'string' || !target.serverId)) {
|
|
4207
4798
|
return { error: 'serverId must name a server (omit it to resolve the channel id alone)' };
|
|
4208
4799
|
}
|
|
4209
|
-
|
|
4800
|
+
// Without a server, the channel id, or its server-qualified name, means
|
|
4801
|
+
// what it means to every channel tool (entriesNamed).
|
|
4802
|
+
const matches = target.serverId
|
|
4803
|
+
? [this.entryAt({ serverId: target.serverId, channelId: target.channelId })].filter((e) => e !== undefined)
|
|
4804
|
+
: this.entriesNamed(target.channelId);
|
|
4210
4805
|
if (matches.length === 0) {
|
|
4211
4806
|
return {
|
|
4212
4807
|
error: target.serverId
|
|
@@ -4215,12 +4810,16 @@ export class ChannelRegistry {
|
|
|
4215
4810
|
};
|
|
4216
4811
|
}
|
|
4217
4812
|
if (matches.length > 1) {
|
|
4813
|
+
// The framework's own speech always names its server, so only a
|
|
4814
|
+
// caller that named none meets this, and it can name one.
|
|
4815
|
+
// Each named by what picks it alone, or by its id and server where
|
|
4816
|
+
// nothing does; "more than one server" only where the servers differ.
|
|
4817
|
+
const names = matches.map((e) => this.quotedShown(refOf(e)));
|
|
4818
|
+
const servers = new Set(matches.map((e) => e.serverId));
|
|
4218
4819
|
return {
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
error: `channel id "${target.channelId}" is on more than one server, and no server was named for these words; ` +
|
|
4223
|
-
'if you need them there, ask whoever runs this host',
|
|
4820
|
+
error: servers.size > 1
|
|
4821
|
+
? `channel id "${target.channelId}" is on more than one server (${names.join(', ')}), and the destination named no server`
|
|
4822
|
+
: `channel id "${target.channelId}" names more than one channel on server "${matches[0].serverId}" (${names.join(', ')})`,
|
|
4224
4823
|
};
|
|
4225
4824
|
}
|
|
4226
4825
|
return { entry: matches[0] };
|
|
@@ -4234,16 +4833,19 @@ export class ChannelRegistry {
|
|
|
4234
4833
|
* publishReceiptOutcome and publishErrorOutcome.
|
|
4235
4834
|
*
|
|
4236
4835
|
* Resolution is exact (resolveDestination): `serverId` plus `channelId`
|
|
4237
|
-
* when the caller has the server, otherwise the channel id
|
|
4238
|
-
*
|
|
4239
|
-
* through whichever server came first). An omitted `serverId` means "not
|
|
4836
|
+
* when the caller has the server, otherwise the channel id, or its
|
|
4837
|
+
* server-qualified name, must mean exactly one channel (a shared id is
|
|
4838
|
+
* refused, never routed through whichever server came first). An omitted `serverId` means "not
|
|
4240
4839
|
* in use"; a supplied one must be a non-empty server id, and an empty or
|
|
4241
4840
|
* non-string selector is refused rather than read as omission. It is not
|
|
4242
|
-
* possible to send into a closed channel
|
|
4243
|
-
*
|
|
4244
|
-
*
|
|
4245
|
-
*
|
|
4246
|
-
*
|
|
4841
|
+
* possible to send into a closed channel that speech opens (speechOpens):
|
|
4842
|
+
* it's opened first (`openSource` names why), once its server and that
|
|
4843
|
+
* server's channels.publish grant are known to take the send, so a send
|
|
4844
|
+
* refused before the open opens nothing; and a failed open sends nothing.
|
|
4845
|
+
* A channel speech leaves as it stands (speechLeavesAsItStands), one the
|
|
4846
|
+
* agent closed or declined itself or a module closed, or a closed one on
|
|
4847
|
+
* a server that's up but whose lifecycle this host doesn't direct, takes
|
|
4848
|
+
* the words as it is, once its queued lifecycle work has settled.
|
|
4247
4849
|
*
|
|
4248
4850
|
* A channel its server declared inbound-only (isInboundOnly) takes nothing
|
|
4249
4851
|
* the host would post, whichever path the words took, a drafts resend
|
|
@@ -4306,30 +4908,41 @@ export class ChannelRegistry {
|
|
|
4306
4908
|
const prevented = (err) => ({
|
|
4307
4909
|
status: 'failed', destination, reason: lifecycleClause(err) + refusalRemedy(err), at: at(),
|
|
4308
4910
|
});
|
|
4911
|
+
// Words go into a channel that's open with no close standing, or into
|
|
4912
|
+
// one speech leaves as it stands (speechLeavesAsItStands): a channel the
|
|
4913
|
+
// agent closed or declined itself, or a module closed, and a closed
|
|
4914
|
+
// channel on a server that's up but whose lifecycle this host doesn't
|
|
4915
|
+
// direct, where nothing opens. Every other channel speech opens first
|
|
4916
|
+
// (speechOpens), and if it doesn't open, nothing goes out. A wait or an
|
|
4917
|
+
// open that fails is read against the channel as it is then, so a close
|
|
4918
|
+
// the agent decides while the words wait lets them go into the channel
|
|
4919
|
+
// as that close leaves it, unless the failure is convergence that never
|
|
4920
|
+
// established its target (refusalGivesWay). A channel the server last
|
|
4921
|
+
// answered for another one takes nothing as it stands (answeredElsewhere).
|
|
4309
4922
|
// Lifecycle work already queued for the channel settles first: words go
|
|
4310
4923
|
// out only into the channel it leaves, and fail visibly if it leaves
|
|
4311
|
-
// none open.
|
|
4924
|
+
// none open and speech would open it.
|
|
4312
4925
|
if (this.hasPendingLifecycle(entry)) {
|
|
4313
4926
|
try {
|
|
4314
4927
|
entry = await this.waitForOpenChannel(entry);
|
|
4315
4928
|
}
|
|
4316
4929
|
catch (err) {
|
|
4317
|
-
|
|
4930
|
+
const now = this.entryAt(refOf(entry));
|
|
4931
|
+
if (!this.refusalGivesWay(err, now))
|
|
4932
|
+
return prevented(err);
|
|
4933
|
+
entry = now;
|
|
4318
4934
|
}
|
|
4319
4935
|
}
|
|
4320
|
-
//
|
|
4321
|
-
//
|
|
4322
|
-
//
|
|
4323
|
-
//
|
|
4324
|
-
//
|
|
4325
|
-
//
|
|
4326
|
-
//
|
|
4327
|
-
//
|
|
4328
|
-
//
|
|
4329
|
-
|
|
4330
|
-
const engagesDecidedClosed = this.getDesiredState(entry.serverId, entry.descriptor.id) === 'closed' &&
|
|
4331
|
-
CapabilityGrant.of(server).has('channels.lifecycle');
|
|
4332
|
-
if (!entry.open || engagesDecidedClosed) {
|
|
4936
|
+
// A send into a closed-but-registered channel (the canonical case: a DM
|
|
4937
|
+
// that woke the agent, since DMs register closed) OPENS it first where
|
|
4938
|
+
// speech opens it (speechOpens), so typing indicators, reaction
|
|
4939
|
+
// machinery and inbound forwarding come alive with the words. If the
|
|
4940
|
+
// open fails, nothing goes out: a half-alive delivery is worse than a
|
|
4941
|
+
// loud marker. A channel decided closed is engaged even while its
|
|
4942
|
+
// transport is still open (a close that failed), as a reply would engage
|
|
4943
|
+
// it, wherever an open can succeed, unless that close is the agent's or
|
|
4944
|
+
// a module's own.
|
|
4945
|
+
if (this.speechOpens(refOf(entry))) {
|
|
4333
4946
|
try {
|
|
4334
4947
|
const { opened } = await this.openChannelNow(entry, openSource);
|
|
4335
4948
|
entry = await this.waitForOpenChannel(entry);
|
|
@@ -4358,7 +4971,10 @@ export class ChannelRegistry {
|
|
|
4358
4971
|
}
|
|
4359
4972
|
}
|
|
4360
4973
|
catch (err) {
|
|
4361
|
-
|
|
4974
|
+
const now = this.entryAt(refOf(entry));
|
|
4975
|
+
if (!this.refusalGivesWay(err, now))
|
|
4976
|
+
return { status: 'failed', destination, reason: openFailedReason(err), at: at() };
|
|
4977
|
+
entry = now;
|
|
4362
4978
|
}
|
|
4363
4979
|
}
|
|
4364
4980
|
// Checked again here, after every await above, the lifecycle work's, the
|
|
@@ -4374,15 +4990,18 @@ export class ChannelRegistry {
|
|
|
4374
4990
|
// its close standing. The loop opens nothing itself. The check then runs
|
|
4375
4991
|
// again on the registration that stands. waitForOpenChannel returns
|
|
4376
4992
|
// only a registration it found open and whose close doesn't stand, or
|
|
4377
|
-
// throws, so the loop ends unless the channel keeps changing.
|
|
4378
|
-
|
|
4993
|
+
// throws, so the loop ends unless the channel keeps changing. A channel
|
|
4994
|
+
// speech leaves as it stands (speechLeavesAsItStands) needs none of
|
|
4995
|
+
// that, and its words go into it as it is.
|
|
4996
|
+
const key = destinationKey({ serverId: entry.serverId, channelId: entry.descriptor.id });
|
|
4379
4997
|
let current = this.channels.get(key);
|
|
4380
|
-
while (current && !isInboundOnly(current) && (!current.open || this.closeStands(current))) {
|
|
4998
|
+
while (current && !isInboundOnly(current) && (!current.open || this.closeStands(current)) && !this.speechLeavesAsItStands(current)) {
|
|
4381
4999
|
try {
|
|
4382
5000
|
entry = await this.waitForOpenChannel(current);
|
|
4383
5001
|
}
|
|
4384
5002
|
catch (err) {
|
|
4385
|
-
|
|
5003
|
+
if (!this.refusalGivesWay(err, this.channels.get(key)))
|
|
5004
|
+
return prevented(err);
|
|
4386
5005
|
}
|
|
4387
5006
|
current = this.channels.get(key);
|
|
4388
5007
|
}
|
|
@@ -4391,6 +5010,17 @@ export class ChannelRegistry {
|
|
|
4391
5010
|
if (isInboundOnly(current)) {
|
|
4392
5011
|
return { status: 'failed', destination, reason: inboundOnlyReason(current), at: at() };
|
|
4393
5012
|
}
|
|
5013
|
+
// A channel the words go into as it stands, with no open to check what
|
|
5014
|
+
// the server reaches by its id, takes nothing where the server answered
|
|
5015
|
+
// its last open for another channel, whenever that was (answeredElsewhere):
|
|
5016
|
+
// the words could reach that other channel.
|
|
5017
|
+
if ((!current.open || this.closeStands(current)) && this.answeredElsewhere.has(current)) {
|
|
5018
|
+
return {
|
|
5019
|
+
status: 'failed', destination, at: at(),
|
|
5020
|
+
reason: `the "${current.serverId}" server answered this channel's last open for a different channel, so words ` +
|
|
5021
|
+
"sent by its id may not reach it; once an open of it is answered for this channel, they go into it again",
|
|
5022
|
+
};
|
|
5023
|
+
}
|
|
4394
5024
|
// A grant reduced during any of the waits holds at once (§6.7): the
|
|
4395
5025
|
// words already on their way take it too. Its refusal is worded for the
|
|
4396
5026
|
// connection that refused (sendRefusal's `checked`).
|
|
@@ -4423,7 +5053,7 @@ export class ChannelRegistry {
|
|
|
4423
5053
|
* that used to live in discord-mcpl.
|
|
4424
5054
|
*/
|
|
4425
5055
|
async deliverSpeech(conversationId, text,
|
|
4426
|
-
/** The turn's FROZEN locus, snapshotted once by the caller (resolveLocus
|
|
5056
|
+
/** The turn's FROZEN locus, with its server, snapshotted once by the caller (resolveLocus
|
|
4427
5057
|
* at turn start) and passed to every segment of the turn. Mandatory:
|
|
4428
5058
|
* there is deliberately no live-resolution fallback here — a late
|
|
4429
5059
|
* dispatch resolving live can read the NEXT turn's trigger state or a
|
|
@@ -4432,21 +5062,23 @@ export class ChannelRegistry {
|
|
|
4432
5062
|
* "this turn is pinned to no locus": fail loudly rather than guess,
|
|
4433
5063
|
* except for an ephemeral run, whose speech is then its caller's
|
|
4434
5064
|
* result. */
|
|
4435
|
-
|
|
5065
|
+
locus,
|
|
4436
5066
|
/** `heldOnFailure`: the caller holds a `failed` delivery's words as a
|
|
4437
5067
|
* draft with its own notice, so no failure marker is written for it.
|
|
4438
5068
|
* An `unknown` outcome still gets its marker. */
|
|
4439
5069
|
opts) {
|
|
4440
5070
|
// `logged` is what the log line and the trace say, where it isn't the
|
|
4441
5071
|
// agent's reason (the two no-locus reasons below).
|
|
4442
|
-
const fail = (
|
|
5072
|
+
const fail = (channel, reason, outcome, logged = reason) => {
|
|
4443
5073
|
const status = outcome.status === 'unknown' ? 'unknown' : 'failed';
|
|
5074
|
+
const channelId = channel?.channelId ?? null;
|
|
4444
5075
|
const detail = outcome.detail !== undefined ? connectorText(outcome.detail) : undefined;
|
|
4445
5076
|
const detailText = detail !== undefined ? `; connector detail: ${detail}` : '';
|
|
4446
5077
|
console.error(`[routeSpeech] ${conversationId}: ${logged}${detailText} — speech ${status === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`);
|
|
4447
5078
|
this.emitTraceFn({
|
|
4448
5079
|
type: 'mcpl:speech-route-failed',
|
|
4449
5080
|
conversationId,
|
|
5081
|
+
...(channel ? { serverId: channel.serverId } : {}),
|
|
4450
5082
|
channelId: channelId ?? '',
|
|
4451
5083
|
reason: logged,
|
|
4452
5084
|
textLen: text.length,
|
|
@@ -4454,7 +5086,10 @@ export class ChannelRegistry {
|
|
|
4454
5086
|
...(detail !== undefined ? { detail } : {}),
|
|
4455
5087
|
});
|
|
4456
5088
|
if (status === 'unknown' || !opts?.heldOnFailure) {
|
|
4457
|
-
this.onRouteFailure?.({
|
|
5089
|
+
this.onRouteFailure?.({
|
|
5090
|
+
conversationId, channelId, ...(channel ? { serverId: channel.serverId } : {}),
|
|
5091
|
+
reason, textLen: text.length, outcome: status, ...(detail !== undefined ? { detail } : {}),
|
|
5092
|
+
});
|
|
4458
5093
|
}
|
|
4459
5094
|
return outcome;
|
|
4460
5095
|
};
|
|
@@ -4467,11 +5102,11 @@ export class ChannelRegistry {
|
|
|
4467
5102
|
// Runtime backstop for JS callers the compiler can't see: an omitted
|
|
4468
5103
|
// locus is a routing bug at the call site, never something to paper over
|
|
4469
5104
|
// with a live re-resolution.
|
|
4470
|
-
if (
|
|
5105
|
+
if (locus === undefined) {
|
|
4471
5106
|
const reason = 'the host lost track of which channel this reply was for; if this keeps happening, ask whoever runs this host';
|
|
4472
5107
|
return fail(null, reason, { status: 'failed', reason, at: Date.now() }, 'caller passed no locus (routing bug: every speech path must snapshot the turn locus)');
|
|
4473
5108
|
}
|
|
4474
|
-
if (!
|
|
5109
|
+
if (!locus) {
|
|
4475
5110
|
// An ephemeral run with no channel of its own: its plain speech is its
|
|
4476
5111
|
// caller's result (takesGlobalFallback). It returns as not delivered,
|
|
4477
5112
|
// with its own reason, and writes no line, trace or notice.
|
|
@@ -4486,10 +5121,10 @@ export class ChannelRegistry {
|
|
|
4486
5121
|
'had one, ask whoever runs this host';
|
|
4487
5122
|
return fail(null, reason, { status: 'failed', reason, at: Date.now() }, 'turn has no locus (no home/trigger channel; nothing to deliver into)');
|
|
4488
5123
|
}
|
|
4489
|
-
const outcome = await this.publish(conversationId, text, { channelId:
|
|
4490
|
-
const channelId = outcome.destination?.channelId ??
|
|
5124
|
+
const outcome = await this.publish(conversationId, text, { serverId: locus.serverId, channelId: locus.channelId, routed: true });
|
|
5125
|
+
const channelId = outcome.destination?.channelId ?? locus.channelId;
|
|
4491
5126
|
if (outcome.status !== 'delivered') {
|
|
4492
|
-
return fail(
|
|
5127
|
+
return fail(outcome.destination ?? locus, outcome.reason ?? 'delivery not confirmed', outcome);
|
|
4493
5128
|
}
|
|
4494
5129
|
console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${logValue(channelId)} (server=${outcome.destination.serverId}, delivered=true)`);
|
|
4495
5130
|
this.emitTraceFn({
|
|
@@ -4508,8 +5143,8 @@ export class ChannelRegistry {
|
|
|
4508
5143
|
return outcome;
|
|
4509
5144
|
}
|
|
4510
5145
|
/** deliverSpeech, reduced to the confirmed delivery (null when there was none). */
|
|
4511
|
-
async routeSpeech(conversationId, text,
|
|
4512
|
-
const outcome = await this.deliverSpeech(conversationId, text,
|
|
5146
|
+
async routeSpeech(conversationId, text, locus) {
|
|
5147
|
+
const outcome = await this.deliverSpeech(conversationId, text, locus);
|
|
4513
5148
|
if (outcome.status !== 'delivered')
|
|
4514
5149
|
return null;
|
|
4515
5150
|
return {
|
|
@@ -4525,10 +5160,14 @@ export class ChannelRegistry {
|
|
|
4525
5160
|
* channel id differs: the subconscious isn't offered channel_list, and is
|
|
4526
5161
|
* given speak_in_channel for the tuned-out channels whose messages it reads.
|
|
4527
5162
|
*/
|
|
4528
|
-
async handleToolPublish(
|
|
4529
|
-
/**
|
|
4530
|
-
*
|
|
4531
|
-
|
|
5163
|
+
async handleToolPublish(
|
|
5164
|
+
/** `serverId` isn't in channel_publish's schema: the framework gives it
|
|
5165
|
+
* with a conversation fork's home, and publishForAgent with a ref. */
|
|
5166
|
+
input, origin,
|
|
5167
|
+
/** Filled for publishForAgent: the channel the call names once it
|
|
5168
|
+
* resolves, and the publish's outcome once a request is attempted, for
|
|
5169
|
+
* its trace, or which check of the destination refused it before
|
|
5170
|
+
* anything went out, for its answer. */
|
|
4532
5171
|
seen, tool = 'channel_publish') {
|
|
4533
5172
|
const findIds = tool === 'channel_publish'
|
|
4534
5173
|
? 'channel_list shows the channel ids.'
|
|
@@ -4551,9 +5190,13 @@ export class ChannelRegistry {
|
|
|
4551
5190
|
// marker, apart from an edit the coalescer delivers on acceptance as the
|
|
4552
5191
|
// agent's first view. An ephemeral run never takes that default
|
|
4553
5192
|
// (takesGlobalFallback), so it names one.
|
|
5193
|
+
// A channel named by a string means what it means to every channel tool
|
|
5194
|
+
// (entriesNamed): one channel, by its id or its server-qualified name,
|
|
5195
|
+
// and a name two servers' channels answer to is refused with them as
|
|
5196
|
+
// candidates, never sent to the first.
|
|
4554
5197
|
const caller = origin?.kind === 'agent' ? origin.agentName : undefined;
|
|
4555
|
-
const
|
|
4556
|
-
if (!channelId) {
|
|
5198
|
+
const fallback = caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel;
|
|
5199
|
+
if (!(input.channelId ?? fallback)) {
|
|
4557
5200
|
return {
|
|
4558
5201
|
success: false,
|
|
4559
5202
|
error: tool === 'speak_in_channel'
|
|
@@ -4565,18 +5208,90 @@ export class ChannelRegistry {
|
|
|
4565
5208
|
isError: true,
|
|
4566
5209
|
};
|
|
4567
5210
|
}
|
|
4568
|
-
|
|
5211
|
+
// A host's ref (publishForAgent, the one caller that gives
|
|
5212
|
+
// speak_in_channel a serverId) names its exact pair, an empty serverId
|
|
5213
|
+
// included, as publish's destination does. A tool's empty serverId is a
|
|
5214
|
+
// parameter filled with nothing, read as none given, as the channel tools
|
|
5215
|
+
// read one.
|
|
5216
|
+
const serverGiven = tool === 'speak_in_channel' ? input.serverId !== undefined : !!input.serverId;
|
|
5217
|
+
// Inside the calling turn, a name one of the turn's own channels answers
|
|
5218
|
+
// to by its id means that channel, as a `>>` target does (ownChannelNamed).
|
|
5219
|
+
const own = input.channelId !== undefined && !serverGiven && origin?.kind === 'agent'
|
|
5220
|
+
? this.ownChannelNamed(input.channelId, origin.own)
|
|
5221
|
+
: undefined;
|
|
5222
|
+
let entry;
|
|
5223
|
+
if (input.channelId === undefined) {
|
|
5224
|
+
// A serverId given with no channelId names the default's id on that
|
|
5225
|
+
// server, held to as one given with a channelId is.
|
|
5226
|
+
entry = this.entryAt(serverGiven ? { serverId: input.serverId, channelId: fallback.channelId } : fallback);
|
|
5227
|
+
}
|
|
5228
|
+
else if (own && 'neither' in own) {
|
|
5229
|
+
if (seen)
|
|
5230
|
+
seen.refusal = 'shared-id';
|
|
5231
|
+
return { success: false, error: this.ownNeitherRefusal(input.channelId, own.neither, 'was posted'), isError: true };
|
|
5232
|
+
}
|
|
5233
|
+
else if (own) {
|
|
5234
|
+
entry = this.entryAt(own.channel);
|
|
5235
|
+
if (!entry) {
|
|
5236
|
+
if (seen)
|
|
5237
|
+
seen.refusal = 'channel-not-registered';
|
|
5238
|
+
return { success: false, error: this.ownChannelGoneRefusal(input.channelId, own.channel, 'publish'), isError: true };
|
|
5239
|
+
}
|
|
5240
|
+
}
|
|
5241
|
+
else if (serverGiven) {
|
|
5242
|
+
entry = this.entryAt({ serverId: input.serverId, channelId: input.channelId });
|
|
5243
|
+
}
|
|
5244
|
+
else {
|
|
5245
|
+
const matches = this.entriesNamed(input.channelId);
|
|
5246
|
+
if (matches.length > 1) {
|
|
5247
|
+
if (seen)
|
|
5248
|
+
seen.refusal = 'shared-id';
|
|
5249
|
+
return {
|
|
5250
|
+
success: false,
|
|
5251
|
+
error: this.sharedNameRefusal(input.channelId, matches, { takesServerId: false, nothing: 'nothing was posted' }),
|
|
5252
|
+
isError: true,
|
|
5253
|
+
};
|
|
5254
|
+
}
|
|
5255
|
+
entry = matches[0];
|
|
5256
|
+
}
|
|
4569
5257
|
if (!entry) {
|
|
4570
5258
|
if (seen)
|
|
4571
5259
|
seen.refusal = 'channel-not-registered';
|
|
4572
|
-
return {
|
|
5260
|
+
return {
|
|
5261
|
+
success: false,
|
|
5262
|
+
// The default channel is gone (its server no longer registers it),
|
|
5263
|
+
// though another server's channel may have its id.
|
|
5264
|
+
// No advice: the default's channel is gone, and another server's
|
|
5265
|
+
// channel with its id isn't where its message came from.
|
|
5266
|
+
error: input.channelId === undefined
|
|
5267
|
+
? serverGiven
|
|
5268
|
+
? `Server "${input.serverId}" has no channel "${fallback.channelId}", the id of the channel this call would default to, so nothing was posted.`
|
|
5269
|
+
: `The channel this would default to, ${this.quotedShown(fallback)}, isn't registered now, so nothing was posted.`
|
|
5270
|
+
// This tool's schema has no serverId, so one given here is a host's
|
|
5271
|
+
// ref (publishForAgent), or one an agent added unasked, and the
|
|
5272
|
+
// refusal offers names its channelId takes instead. (A fork's guard
|
|
5273
|
+
// refuses a home its server no longer registers before this runs.)
|
|
5274
|
+
: serverGiven
|
|
5275
|
+
? `No channel "${input.channelId}" is on server "${input.serverId}", so nothing was posted. ${this.elsewhereText(input.channelId, false)}`
|
|
5276
|
+
: `No channel has the id "${input.channelId}", so nothing was posted. ${findIds}`,
|
|
5277
|
+
isError: true,
|
|
5278
|
+
};
|
|
4573
5279
|
}
|
|
5280
|
+
// From here, the channel's own id, whichever name the call gave for it,
|
|
5281
|
+
// and the name the agent is shown it by (nameShown).
|
|
5282
|
+
const channelId = entry.descriptor.id;
|
|
5283
|
+
// The channel the call names, for a caller that reports a refusal before
|
|
5284
|
+
// the send (publishForAgent's trace).
|
|
5285
|
+
if (seen)
|
|
5286
|
+
seen.resolved = refOf(entry);
|
|
5287
|
+
const shown = this.nameShown(refOf(entry));
|
|
5288
|
+
const quoted = this.quotedShown(refOf(entry));
|
|
4574
5289
|
if (isInboundOnly(entry)) {
|
|
4575
5290
|
if (seen)
|
|
4576
5291
|
seen.refusal = 'inbound-only';
|
|
4577
5292
|
return {
|
|
4578
5293
|
success: false,
|
|
4579
|
-
error: `Channel ${
|
|
5294
|
+
error: `Channel ${shown} is ${inboundOnlyReason(entry)}`,
|
|
4580
5295
|
isError: true,
|
|
4581
5296
|
};
|
|
4582
5297
|
}
|
|
@@ -4588,7 +5303,7 @@ export class ChannelRegistry {
|
|
|
4588
5303
|
const reach = this.serverReach(entry.serverId);
|
|
4589
5304
|
seen.refusal = reach === 'up' ? 'not-permitted' : `server-${reach}`;
|
|
4590
5305
|
}
|
|
4591
|
-
return { success: false, error: `Nothing was posted to channel
|
|
5306
|
+
return { success: false, error: `Nothing was posted to channel ${quoted}: ${this.sendRefusal(entry.serverId)}.`, isError: true };
|
|
4592
5307
|
}
|
|
4593
5308
|
const publishParams = {
|
|
4594
5309
|
conversationId: '', // Framework will fill this when wired
|
|
@@ -4621,11 +5336,15 @@ export class ChannelRegistry {
|
|
|
4621
5336
|
return { success: true, data: result };
|
|
4622
5337
|
// The connector's data may say what a multi-part send already posted.
|
|
4623
5338
|
const detail = outcome.status === 'unknown' && outcome.detail !== undefined ? ` Connector detail: ${connectorText(outcome.detail)}` : '';
|
|
5339
|
+
// Named as it's shown now, after the send's await: registrations may have
|
|
5340
|
+
// changed meanwhile, and the name shown before it may now pick another
|
|
5341
|
+
// channel.
|
|
5342
|
+
const sentTo = this.quotedShown(refOf(entry));
|
|
4624
5343
|
return {
|
|
4625
5344
|
success: false,
|
|
4626
5345
|
error: (outcome.status === 'failed'
|
|
4627
|
-
? `Nothing was posted to channel
|
|
4628
|
-
: `Posting to channel
|
|
5346
|
+
? `Nothing was posted to channel ${sentTo}: ${outcome.reason}.`
|
|
5347
|
+
: `Posting to channel ${sentTo} wasn't confirmed: ${outcome.reason}. ` +
|
|
4629
5348
|
'It may or may not have been posted, so sending it again could post it twice.') + detail,
|
|
4630
5349
|
isError: true,
|
|
4631
5350
|
};
|