@animalabs/agent-framework 0.22.3 → 0.23.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 +105 -3
- package/dist/src/agent.d.ts +36 -10
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +76 -32
- package/dist/src/agent.js.map +1 -1
- package/dist/src/code-execution/index.d.ts +1 -1
- package/dist/src/code-execution/index.d.ts.map +1 -1
- package/dist/src/code-execution/index.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +34 -2
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +72 -16
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +3 -2
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +47 -7
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts +12 -7
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +59 -11
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/config-checks.d.ts +2 -2
- package/dist/src/config-checks.d.ts.map +1 -1
- package/dist/src/config-checks.js +6 -2
- package/dist/src/config-checks.js.map +1 -1
- package/dist/src/framework.d.ts +744 -120
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +3546 -906
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +13 -6
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/held-writes.d.ts +16 -0
- package/dist/src/held-writes.d.ts.map +1 -0
- package/dist/src/held-writes.js +56 -0
- package/dist/src/held-writes.js.map +1 -0
- package/dist/src/index.d.ts +7 -3
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +4 -0
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
- package/dist/src/mcpl/capability-grant.js +1 -1
- package/dist/src/mcpl/capability-grant.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +95 -18
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +578 -150
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/convert-block.d.ts +2 -2
- package/dist/src/mcpl/convert-block.d.ts.map +1 -1
- package/dist/src/mcpl/convert-block.js +6 -7
- package/dist/src/mcpl/convert-block.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 +3 -1
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/inference-router.d.ts +4 -2
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.js +6 -7
- package/dist/src/mcpl/inference-router.js.map +1 -1
- package/dist/src/mcpl/log-value.d.ts +7 -0
- package/dist/src/mcpl/log-value.d.ts.map +1 -1
- package/dist/src/mcpl/log-value.js +9 -0
- package/dist/src/mcpl/log-value.js.map +1 -1
- package/dist/src/mcpl/modern-connection.d.ts +227 -0
- package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
- package/dist/src/mcpl/modern-connection.js +1088 -0
- package/dist/src/mcpl/modern-connection.js.map +1 -0
- package/dist/src/mcpl/prefix-rule.d.ts +18 -0
- package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
- package/dist/src/mcpl/prefix-rule.js +25 -0
- package/dist/src/mcpl/prefix-rule.js.map +1 -0
- package/dist/src/mcpl/protocol-family.d.ts +179 -0
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
- package/dist/src/mcpl/protocol-family.js +697 -0
- package/dist/src/mcpl/protocol-family.js.map +1 -0
- package/dist/src/mcpl/push-coalescer.d.ts +38 -19
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +52 -23
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +243 -76
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +63 -18
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +117 -32
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +326 -30
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +995 -212
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts +25 -5
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +51 -12
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
- package/dist/src/mcpl/tool-result-normalize.js +232 -0
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +56 -13
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +206 -70
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +66 -20
- 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 +2 -1
- package/dist/src/mcpl/visible-content.d.ts.map +1 -1
- package/dist/src/mcpl/visible-content.js +2 -1
- package/dist/src/mcpl/visible-content.js.map +1 -1
- package/dist/src/module-registry.d.ts +16 -6
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +65 -6
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/api/index.d.ts +1 -1
- package/dist/src/modules/api/index.d.ts.map +1 -1
- package/dist/src/modules/api/index.js +3 -2
- package/dist/src/modules/api/index.js.map +1 -1
- package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
- package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
- package/dist/src/modules/discord/discord-js-client.js +268 -65
- package/dist/src/modules/discord/discord-js-client.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts +27 -5
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +366 -76
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/discord/refusal.d.ts +14 -0
- package/dist/src/modules/discord/refusal.d.ts.map +1 -0
- package/dist/src/modules/discord/refusal.js +14 -0
- package/dist/src/modules/discord/refusal.js.map +1 -0
- package/dist/src/modules/discord/types.d.ts +17 -7
- package/dist/src/modules/discord/types.d.ts.map +1 -1
- package/dist/src/modules/health/index.d.ts.map +1 -1
- package/dist/src/modules/health/index.js +6 -2
- package/dist/src/modules/health/index.js.map +1 -1
- package/dist/src/modules/index.d.ts +1 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.js +45 -5
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
- package/dist/src/modules/workspace/effects.d.ts +2 -1
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +19 -9
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +214 -42
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +667 -113
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +240 -8
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +554 -24
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +10 -1
- 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 +42 -0
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +130 -25
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/silent-heartbeat.d.ts +112 -39
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js +100 -29
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/tool-args.d.ts +40 -0
- package/dist/src/tool-args.d.ts.map +1 -0
- package/dist/src/tool-args.js +207 -0
- package/dist/src/tool-args.js.map +1 -0
- package/dist/src/tool-presentation.d.ts.map +1 -1
- package/dist/src/tool-presentation.js +9 -2
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/types/agent.d.ts +27 -17
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +8 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +17 -2
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +54 -10
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +2 -2
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +3 -1
- package/src/agent.ts +99 -36
- package/src/code-execution/index.ts +1 -0
- package/src/code-execution/py-runner.ts +104 -17
- package/src/code-execution/runtime-py.ts +47 -7
- package/src/code-execution/tool-definition.ts +65 -11
- package/src/config-checks.ts +4 -2
- package/src/framework.ts +3633 -956
- package/src/gate/event-gate.ts +14 -6
- package/src/held-writes.ts +59 -0
- package/src/index.ts +19 -4
- package/src/mcpl/capability-grant.ts +1 -1
- package/src/mcpl/channel-registry.ts +613 -160
- package/src/mcpl/convert-block.ts +15 -7
- package/src/mcpl/index.ts +14 -1
- package/src/mcpl/inference-router.ts +8 -7
- package/src/mcpl/log-value.ts +10 -0
- package/src/mcpl/modern-connection.ts +1182 -0
- package/src/mcpl/prefix-rule.ts +32 -0
- package/src/mcpl/protocol-family.ts +691 -0
- package/src/mcpl/push-coalescer.ts +67 -29
- package/src/mcpl/reference-fetcher.ts +288 -82
- package/src/mcpl/references.ts +123 -33
- package/src/mcpl/server-connection.ts +1050 -221
- package/src/mcpl/server-registry.ts +50 -12
- package/src/mcpl/tool-result-normalize.ts +294 -0
- package/src/mcpl/transport.ts +204 -62
- package/src/mcpl/types.ts +67 -20
- package/src/mcpl/visible-content.ts +2 -1
- package/src/module-registry.ts +68 -13
- package/src/modules/api/index.ts +3 -2
- package/src/modules/discord/discord-js-client.ts +288 -72
- package/src/modules/discord/index.ts +380 -77
- package/src/modules/discord/refusal.ts +13 -0
- package/src/modules/discord/types.ts +17 -9
- package/src/modules/health/index.ts +6 -2
- package/src/modules/index.ts +1 -1
- package/src/modules/workspace/disk-agreement.ts +46 -5
- package/src/modules/workspace/effects.ts +18 -7
- package/src/modules/workspace/index.ts +722 -124
- package/src/modules/workspace/observe.ts +591 -22
- package/src/modules/workspace/types.ts +10 -1
- package/src/modules/workspace/watcher.ts +126 -25
- package/src/silent-heartbeat.ts +184 -59
- package/src/tool-args.ts +213 -0
- package/src/tool-presentation.ts +9 -2
- package/src/types/agent.ts +27 -17
- package/src/types/events.ts +8 -0
- package/src/types/framework.ts +17 -2
- package/src/types/module.ts +55 -10
- package/src/types/trace.ts +2 -2
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* - Build channel context for beforeInference params
|
|
14
14
|
*/
|
|
15
15
|
import { convertBlock } from './convert-block.js';
|
|
16
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
16
17
|
import { ensureStateRegistered } from '../module-registry.js';
|
|
17
18
|
import { expandCoreTags } from './tags.js';
|
|
18
19
|
import { coalesceRejectionReason, EmptyContentError, validateCoalescedContent } from './push-coalescer.js';
|
|
@@ -26,7 +27,42 @@ import { readSkipReplyWake } from './skip-reply-wake.js';
|
|
|
26
27
|
// Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive)
|
|
27
28
|
// ============================================================================
|
|
28
29
|
const TYPING_INTERVAL_MS = 7_000;
|
|
30
|
+
const MAX_CHANNEL_LIFECYCLE_ATTEMPTS = 5;
|
|
31
|
+
/** A successful RPC can still fail to establish the latest channel target. */
|
|
32
|
+
class ChannelLifecycleConvergenceError extends Error {
|
|
33
|
+
}
|
|
34
|
+
/** A channels/open answered for a channel other than the one it named. */
|
|
35
|
+
class ChannelOpenSubstitutionError extends Error {
|
|
36
|
+
}
|
|
37
|
+
/** An operation queued for a server's registrations that were removed. */
|
|
38
|
+
class ChannelLifecycleCancelledError extends Error {
|
|
39
|
+
}
|
|
29
40
|
const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle';
|
|
41
|
+
/**
|
|
42
|
+
* What a channels/open answer says about which channel the server opened:
|
|
43
|
+
* its `channel.id`, or else the `channelId` of a history item. Each is
|
|
44
|
+
* evidence. A string other than the requested id names another channel (ids
|
|
45
|
+
* compare exactly, as the server's traffic is matched). Null or absence names
|
|
46
|
+
* no channel, and gives undefined, as does an answer that names none at all.
|
|
47
|
+
* Any other value is malformed: it names nothing the host could register.
|
|
48
|
+
*/
|
|
49
|
+
function substitutedChannel(result, requested) {
|
|
50
|
+
const named = (value) => {
|
|
51
|
+
if (value === undefined || value === null || value === requested)
|
|
52
|
+
return undefined;
|
|
53
|
+
return typeof value === 'string' ? { kind: 'other', id: value } : { kind: 'malformed', value };
|
|
54
|
+
};
|
|
55
|
+
const answered = named(result?.channel?.id);
|
|
56
|
+
if (answered)
|
|
57
|
+
return answered;
|
|
58
|
+
const history = Array.isArray(result?.history) ? result.history : [];
|
|
59
|
+
for (const item of history) {
|
|
60
|
+
const from = named(item?.channelId);
|
|
61
|
+
if (from)
|
|
62
|
+
return from;
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
30
66
|
/**
|
|
31
67
|
* Durable "which label did this channelId have, each time we saw it" log.
|
|
32
68
|
* `resolveProseTarget()` resolves a label to an id only for channels
|
|
@@ -85,9 +121,25 @@ function shallowEqualRecord(a, b) {
|
|
|
85
121
|
}
|
|
86
122
|
return true;
|
|
87
123
|
}
|
|
124
|
+
function sameChannelTarget(a, b) {
|
|
125
|
+
return a.type === b.type && isDeepStrictEqual(a.address, b.address);
|
|
126
|
+
}
|
|
88
127
|
// ============================================================================
|
|
89
128
|
// Channel Tool Definitions
|
|
90
129
|
// ============================================================================
|
|
130
|
+
/**
|
|
131
|
+
* `journal`'s description. It tells the model to call `skip_reply` to end the
|
|
132
|
+
* turn only where the agent may call it: the framework builds each agent's
|
|
133
|
+
* surface with that agent's permission, and a refused `skip_reply` leaves the
|
|
134
|
+
* turn going on.
|
|
135
|
+
*/
|
|
136
|
+
export function journalToolDescription(canSkipReply) {
|
|
137
|
+
return 'Write an entry in your private journal. The entry stays in your own context and ' +
|
|
138
|
+
'memory and is NOT sent to any channel, surface or person. Use it for anything longer ' +
|
|
139
|
+
'than a line that you want to keep for yourself — reflections, what you decided and ' +
|
|
140
|
+
'why, notes for later. It does not end your turn and does not affect where ordinary ' +
|
|
141
|
+
'text is routed' + (canSkipReply ? '; to end the turn without replying, call skip_reply afterwards.' : '.');
|
|
142
|
+
}
|
|
91
143
|
const CHANNEL_TOOL_DEFINITIONS = [
|
|
92
144
|
{
|
|
93
145
|
name: 'channel_list',
|
|
@@ -149,11 +201,11 @@ const CHANNEL_TOOL_DEFINITIONS = [
|
|
|
149
201
|
},
|
|
150
202
|
{
|
|
151
203
|
name: 'channel_publish',
|
|
152
|
-
description: 'Publish a message to a channel. If channelId is omitted, publishes to the most recent incoming channel.',
|
|
204
|
+
description: 'Publish a message to a channel. If channelId is omitted, publishes to the channel this conversation is bound to, if any, else to the most recent incoming channel. A subagent has neither, and must name one.',
|
|
153
205
|
inputSchema: {
|
|
154
206
|
type: 'object',
|
|
155
207
|
properties: {
|
|
156
|
-
channelId: { type: 'string', description: 'ID of the channel to publish to (defaults to the most recent incoming channel)' },
|
|
208
|
+
channelId: { type: 'string', description: 'ID of the channel to publish to (defaults to the conversation\'s bound channel, else the most recent incoming channel; required of a subagent)' },
|
|
157
209
|
content: { type: 'string', description: 'Text content to publish' },
|
|
158
210
|
text: { type: 'string', description: 'Alias for content' },
|
|
159
211
|
},
|
|
@@ -162,6 +214,9 @@ const CHANNEL_TOOL_DEFINITIONS = [
|
|
|
162
214
|
},
|
|
163
215
|
{
|
|
164
216
|
name: 'think',
|
|
217
|
+
// No agent's surface shows this description: getToolsForAgent gives every
|
|
218
|
+
// agent its own think (buildThinkTool), which names skip_reply only where
|
|
219
|
+
// that agent may call it.
|
|
165
220
|
description: 'Reason privately. The content stays in your own context and is NOT sent to any ' +
|
|
166
221
|
'channel or surface. Same-round routing of ordinary text beside think() depends on ' +
|
|
167
222
|
'your current same_round_think_text_policy; inspect or change it with agent_settings. ' +
|
|
@@ -180,11 +235,7 @@ const CHANNEL_TOOL_DEFINITIONS = [
|
|
|
180
235
|
},
|
|
181
236
|
{
|
|
182
237
|
name: 'journal',
|
|
183
|
-
description:
|
|
184
|
-
'memory and is NOT sent to any channel, surface or person. Use it for anything longer ' +
|
|
185
|
-
'than a line that you want to keep for yourself — reflections, what you decided and ' +
|
|
186
|
-
'why, notes for later. It does not end your turn and does not affect where ordinary ' +
|
|
187
|
-
'text is routed; to end the turn without replying, call skip_reply afterwards.',
|
|
238
|
+
description: journalToolDescription(true),
|
|
188
239
|
inputSchema: {
|
|
189
240
|
type: 'object',
|
|
190
241
|
properties: {
|
|
@@ -246,7 +297,12 @@ export function connectorText(value) {
|
|
|
246
297
|
* a failed request this way.
|
|
247
298
|
*/
|
|
248
299
|
function publishErrorOutcome(err, destination, at) {
|
|
249
|
-
|
|
300
|
+
// This host's own words for a request that wasn't sent or wasn't answered
|
|
301
|
+
// are sentences, and every composition of the reason adds its own ending,
|
|
302
|
+
// so one final period comes off them. A server's error answer keeps its
|
|
303
|
+
// words as they came.
|
|
304
|
+
const text = connectorText(err instanceof Error ? err.message : String(err));
|
|
305
|
+
const message = err instanceof McplRequestError && err.outcome !== 'error-response' ? text.replace(/(?<!\.)\.$/, '') : text;
|
|
250
306
|
const notSent = err instanceof McplRequestError && err.outcome === 'not-sent';
|
|
251
307
|
const detail = err instanceof McplRequestError ? err.data : undefined;
|
|
252
308
|
return {
|
|
@@ -310,6 +366,7 @@ export class ChannelRegistry {
|
|
|
310
366
|
onChannelAutoOpened;
|
|
311
367
|
homeChannelResolver;
|
|
312
368
|
activeChannelResolver;
|
|
369
|
+
takesGlobalFallback;
|
|
313
370
|
store;
|
|
314
371
|
/** Registered channels, keyed by `{serverId}:{channelId}`. */
|
|
315
372
|
channels = new Map();
|
|
@@ -378,6 +435,41 @@ export class ChannelRegistry {
|
|
|
378
435
|
/** One-time migration inputs from the retired recipe auto-open policy. */
|
|
379
436
|
legacyPolicies = new Map();
|
|
380
437
|
migratedLegacyPolicies = new Set();
|
|
438
|
+
// Keep one transport operation in flight per logical channel, including
|
|
439
|
+
// across descriptor replacement. Settled tails release themselves; a
|
|
440
|
+
// failed operation must not poison later decisions.
|
|
441
|
+
channelLifecycleTails = new Map();
|
|
442
|
+
// Bumped when a server's registrations are removed: an operation queued
|
|
443
|
+
// before that is cancelled when it would run, rather than acting on a
|
|
444
|
+
// closing connection or on a later re-registration's entries.
|
|
445
|
+
serverRemovals = new Map();
|
|
446
|
+
// Operations queued or running per channel that will still read its
|
|
447
|
+
// target before confirming anything. Unlike the tail, which settles a few
|
|
448
|
+
// microtasks after its operation's last check, this drops at that check.
|
|
449
|
+
lifecycleOutstanding = new Map();
|
|
450
|
+
// Losing an open receipt does not confirm closure. Keep that distinction
|
|
451
|
+
// private while preserving the public open:boolean availability projection.
|
|
452
|
+
unconfirmedChannelTargets = new WeakSet();
|
|
453
|
+
invalidateTransportConfirmation(entry) {
|
|
454
|
+
entry.open = false;
|
|
455
|
+
this.unconfirmedChannelTargets.add(entry);
|
|
456
|
+
}
|
|
457
|
+
/** Whether the host directs this server's channel lifecycle (§14.1: its
|
|
458
|
+
* `channels.lifecycle` grant). Where it doesn't, the server opens and
|
|
459
|
+
* closes its own channels, and sends no receipt the host could wait for. */
|
|
460
|
+
directsLifecycle(serverId) {
|
|
461
|
+
return CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.lifecycle');
|
|
462
|
+
}
|
|
463
|
+
/** A registration's entry: nothing about its transport is known until a
|
|
464
|
+
* receipt for its target confirms it open or closed. */
|
|
465
|
+
registerEntry(key, serverId, descriptor) {
|
|
466
|
+
const entry = { serverId, descriptor, open: false };
|
|
467
|
+
this.channels.set(key, entry);
|
|
468
|
+
this.unconfirmedChannelTargets.add(entry);
|
|
469
|
+
}
|
|
470
|
+
hasPendingLifecycle(entry) {
|
|
471
|
+
return this.channelLifecycleTails.has(this.lifecycleKey(entry.serverId, entry.descriptor.id));
|
|
472
|
+
}
|
|
381
473
|
handleCoalescedIncoming;
|
|
382
474
|
acceptInbound;
|
|
383
475
|
channelTools;
|
|
@@ -405,6 +497,7 @@ export class ChannelRegistry {
|
|
|
405
497
|
this.onChannelAutoOpened = options?.onChannelAutoOpened;
|
|
406
498
|
this.homeChannelResolver = options?.homeChannelResolver;
|
|
407
499
|
this.activeChannelResolver = options?.activeChannelResolver;
|
|
500
|
+
this.takesGlobalFallback = options?.takesGlobalFallback;
|
|
408
501
|
this.store = options?.store;
|
|
409
502
|
this.initializeLifecycleStore();
|
|
410
503
|
this.initializeLabelHistoryStore();
|
|
@@ -440,11 +533,7 @@ export class ChannelRegistry {
|
|
|
440
533
|
continue;
|
|
441
534
|
}
|
|
442
535
|
const key = `${serverId}:${channel.id}`;
|
|
443
|
-
this.
|
|
444
|
-
serverId,
|
|
445
|
-
descriptor: channel,
|
|
446
|
-
open: false,
|
|
447
|
-
});
|
|
536
|
+
this.registerEntry(key, serverId, channel);
|
|
448
537
|
this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
449
538
|
registeredIds.push(channel.id);
|
|
450
539
|
results.push({ id: channel.id, accepted: true });
|
|
@@ -477,6 +566,7 @@ export class ChannelRegistry {
|
|
|
477
566
|
// channels/register — a changed-set cannot smuggle in what register
|
|
478
567
|
// would have rejected (PR #79 review blocker 5).
|
|
479
568
|
const addedResults = [];
|
|
569
|
+
const retargeted = [];
|
|
480
570
|
// Process removed channels — itemized truthfully: removal of a channel
|
|
481
571
|
// we never had is reported, not silently accepted.
|
|
482
572
|
if (params.removed) {
|
|
@@ -505,6 +595,21 @@ export class ChannelRegistry {
|
|
|
505
595
|
this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, channelId: channel.id, reason: 'update for unregistered channel' });
|
|
506
596
|
continue;
|
|
507
597
|
}
|
|
598
|
+
// A live subscription to the old target does not confirm the new
|
|
599
|
+
// type/address. Label-only updates preserve that confirmation. A
|
|
600
|
+
// channel decided open (or tuned out) is opened at its new target.
|
|
601
|
+
if (!sameChannelTarget(existing.descriptor, channel)) {
|
|
602
|
+
this.invalidateTransportConfirmation(existing);
|
|
603
|
+
// An operation queued or running for this channel reads its target
|
|
604
|
+
// again before it confirms anything, and after a failed RPC, so it
|
|
605
|
+
// opens the new one. With none, a channel decided open (or tuned
|
|
606
|
+
// out) is reconciled here.
|
|
607
|
+
const desired = this.getDesiredState(serverId, channel.id);
|
|
608
|
+
if ((desired === 'open' || desired === 'tuned-out') &&
|
|
609
|
+
!this.lifecycleOutstanding.has(this.lifecycleKey(serverId, channel.id))) {
|
|
610
|
+
retargeted.push(channel);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
508
613
|
existing.descriptor = channel;
|
|
509
614
|
this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
510
615
|
addedResults.push({ id: channel.id, accepted: true });
|
|
@@ -521,11 +626,7 @@ export class ChannelRegistry {
|
|
|
521
626
|
continue;
|
|
522
627
|
}
|
|
523
628
|
const key = `${serverId}:${channel.id}`;
|
|
524
|
-
this.
|
|
525
|
-
serverId,
|
|
526
|
-
descriptor: channel,
|
|
527
|
-
open: false,
|
|
528
|
-
});
|
|
629
|
+
this.registerEntry(key, serverId, channel);
|
|
529
630
|
this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
530
631
|
addedResults.push({ id: channel.id, accepted: true });
|
|
531
632
|
accepted.push(channel);
|
|
@@ -538,8 +639,9 @@ export class ChannelRegistry {
|
|
|
538
639
|
// deadlocks both sides until one times out (#160). The verdicts are
|
|
539
640
|
// settled above, so nothing in the response depends on reconciling.
|
|
540
641
|
responder?.respond({ results: addedResults });
|
|
541
|
-
|
|
542
|
-
|
|
642
|
+
const reconcile = [...accepted, ...retargeted];
|
|
643
|
+
if (reconcile.length > 0)
|
|
644
|
+
await this.reconcileChannels(serverId, reconcile);
|
|
543
645
|
this.emitTraceFn({
|
|
544
646
|
type: 'mcpl:channels-changed',
|
|
545
647
|
serverId,
|
|
@@ -706,15 +808,34 @@ export class ChannelRegistry {
|
|
|
706
808
|
// exactly the authority a self-attested channel would be stealing).
|
|
707
809
|
// A coalesced item is "accepted" only once the coalescer admits it, so
|
|
708
810
|
// for those this runs after the hook (below).
|
|
811
|
+
const incomingEntry = this.channels.get(incomingKey);
|
|
709
812
|
const markAccepted = () => {
|
|
710
813
|
this.defaultPublishChannel = message.channelId;
|
|
711
814
|
this.defaultPublishMessageId = message.messageId;
|
|
712
815
|
this.defaultPublishThreadId = message.threadId;
|
|
713
|
-
//
|
|
714
|
-
// the
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
|
|
816
|
+
// Where the host directs this server's lifecycle, only a receipt for
|
|
817
|
+
// the current target confirms it: traffic shows the transport open
|
|
818
|
+
// only for a confirmed target, and only for a channel still decided
|
|
819
|
+
// open or tuned out. After a retarget, traffic for this id may still
|
|
820
|
+
// come from the old subscription, and a close receipt that landed
|
|
821
|
+
// first (or during the coalescer's await) stands. Where the host
|
|
822
|
+
// doesn't direct it, the server opens its own channels and its
|
|
823
|
+
// traffic is the only evidence the host gets, so it marks the
|
|
824
|
+
// channel open, as it always has. The entry is the one the message
|
|
825
|
+
// was accepted for: one a re-registration put in its place meanwhile
|
|
826
|
+
// waits for its own evidence. Durable desired state still changes
|
|
827
|
+
// exclusively through lifecycle operations.
|
|
828
|
+
if (this.directsLifecycle(serverId)) {
|
|
829
|
+
if (this.unconfirmedChannelTargets.has(incomingEntry))
|
|
830
|
+
return;
|
|
831
|
+
const desired = this.getDesiredState(serverId, message.channelId);
|
|
832
|
+
if (desired !== 'open' && desired !== 'tuned-out')
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
else {
|
|
836
|
+
this.unconfirmedChannelTargets.delete(incomingEntry);
|
|
837
|
+
}
|
|
838
|
+
incomingEntry.open = true;
|
|
718
839
|
};
|
|
719
840
|
if (!coalesced)
|
|
720
841
|
markAccepted();
|
|
@@ -1044,7 +1165,7 @@ export class ChannelRegistry {
|
|
|
1044
1165
|
case 'channel_decline':
|
|
1045
1166
|
return this.handleToolDecline(input);
|
|
1046
1167
|
case 'channel_publish':
|
|
1047
|
-
return this.handleToolPublish(input);
|
|
1168
|
+
return this.handleToolPublish(input, origin);
|
|
1048
1169
|
case 'think':
|
|
1049
1170
|
return this.handleToolThink(input);
|
|
1050
1171
|
case 'journal':
|
|
@@ -1072,9 +1193,15 @@ export class ChannelRegistry {
|
|
|
1072
1193
|
// (heartbeats). Without this, a fork
|
|
1073
1194
|
// was advertised the global locus but published somewhere else; and a trunk
|
|
1074
1195
|
// agent was told the wrong channel under concurrency.
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
const
|
|
1196
|
+
// Each lookup is keyed on whether a name was given, not its truthiness:
|
|
1197
|
+
// '' is a name an ephemeral run can have, and resolveLocus reads it too.
|
|
1198
|
+
const home = agentName !== undefined ? this.homeChannelResolver?.(agentName) : undefined;
|
|
1199
|
+
const active = agentName !== undefined ? this.activeChannelResolver?.(agentName) : undefined;
|
|
1200
|
+
// An ephemeral run takes no global fallback (takesGlobalFallback): neither
|
|
1201
|
+
// the latest inbound message's channel as its outgoing one, nor that
|
|
1202
|
+
// message as the one it is replying to.
|
|
1203
|
+
const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
|
|
1204
|
+
const outgoing = home ?? active ?? fallback;
|
|
1078
1205
|
if (openChannels.length === 0 && !outgoing) {
|
|
1079
1206
|
return undefined;
|
|
1080
1207
|
}
|
|
@@ -1082,9 +1209,9 @@ export class ChannelRegistry {
|
|
|
1082
1209
|
// Incoming: the most-recent inbound message (what the agent is replying to).
|
|
1083
1210
|
// Left process-global — per-channel inbound tracking (the right messageId for
|
|
1084
1211
|
// a fork's own channel) is a separate concern from the outbound routing fix.
|
|
1085
|
-
if (
|
|
1212
|
+
if (fallback && this.defaultPublishMessageId) {
|
|
1086
1213
|
context.incoming = {
|
|
1087
|
-
channelId:
|
|
1214
|
+
channelId: fallback,
|
|
1088
1215
|
messageId: this.defaultPublishMessageId,
|
|
1089
1216
|
threadId: this.defaultPublishThreadId,
|
|
1090
1217
|
};
|
|
@@ -1110,6 +1237,10 @@ export class ChannelRegistry {
|
|
|
1110
1237
|
* server's channels don't linger and route speech into the void.
|
|
1111
1238
|
*/
|
|
1112
1239
|
removeServer(serverId) {
|
|
1240
|
+
// Every lifecycle operation queued for this server is cancelled when it
|
|
1241
|
+
// would run (ChannelLifecycleCancelledError), so none acts on a closing
|
|
1242
|
+
// connection or on a later re-registration's entries.
|
|
1243
|
+
this.serverRemovals.set(serverId, (this.serverRemovals.get(serverId) ?? 0) + 1);
|
|
1113
1244
|
for (const [key, entry] of this.channels) {
|
|
1114
1245
|
if (entry.serverId !== serverId)
|
|
1115
1246
|
continue;
|
|
@@ -1728,6 +1859,223 @@ export class ChannelRegistry {
|
|
|
1728
1859
|
: 'default-closed');
|
|
1729
1860
|
return channel.initiallyOpen !== true && byPolicy;
|
|
1730
1861
|
}
|
|
1862
|
+
/**
|
|
1863
|
+
* Apply current durable intent, serializing the transport rather than the
|
|
1864
|
+
* server's registration ACK. Re-read after every await: registration,
|
|
1865
|
+
* descriptor updates, or a newer decision may have replaced our snapshot.
|
|
1866
|
+
*/
|
|
1867
|
+
applyDesiredChannelState(serverId, channelId, options) {
|
|
1868
|
+
const lifecycleKey = this.lifecycleKey(serverId, channelId);
|
|
1869
|
+
const removals = this.serverRemovals.get(serverId) ?? 0;
|
|
1870
|
+
const previous = this.channelLifecycleTails.get(lifecycleKey) ?? Promise.resolve();
|
|
1871
|
+
this.lifecycleOutstanding.set(lifecycleKey, (this.lifecycleOutstanding.get(lifecycleKey) ?? 0) + 1);
|
|
1872
|
+
const operation = previous.catch(() => { }).then(() => this.runLifecycleOperation(serverId, channelId, removals, options));
|
|
1873
|
+
// Keep the rejection visible to existing joiners, while observing it
|
|
1874
|
+
// immediately and letting each queued successor start after either result.
|
|
1875
|
+
const tail = operation.then(() => { });
|
|
1876
|
+
this.channelLifecycleTails.set(lifecycleKey, tail);
|
|
1877
|
+
const cleanup = () => {
|
|
1878
|
+
if (this.channelLifecycleTails.get(lifecycleKey) === tail) {
|
|
1879
|
+
this.channelLifecycleTails.delete(lifecycleKey);
|
|
1880
|
+
}
|
|
1881
|
+
};
|
|
1882
|
+
void tail.then(cleanup, cleanup);
|
|
1883
|
+
return operation;
|
|
1884
|
+
}
|
|
1885
|
+
/** One queued lifecycle operation, run when its turn in the queue comes. */
|
|
1886
|
+
async runLifecycleOperation(serverId, channelId, removals, options) {
|
|
1887
|
+
const channelKey = `${serverId}:${channelId}`;
|
|
1888
|
+
const lifecycleKey = this.lifecycleKey(serverId, channelId);
|
|
1889
|
+
try {
|
|
1890
|
+
let attempts = 0;
|
|
1891
|
+
for (;;) {
|
|
1892
|
+
if ((this.serverRemovals.get(serverId) ?? 0) !== removals) {
|
|
1893
|
+
throw new ChannelLifecycleCancelledError(`Channel ${channelId}: its server's registrations were removed while this operation was queued`);
|
|
1894
|
+
}
|
|
1895
|
+
const entry = this.channels.get(channelKey);
|
|
1896
|
+
if (!entry)
|
|
1897
|
+
throw new Error(`Channel is no longer registered: ${channelId}`);
|
|
1898
|
+
const server = this.serverRegistry.getServer(serverId);
|
|
1899
|
+
if (!server)
|
|
1900
|
+
throw new Error(`Server not found: ${serverId}`);
|
|
1901
|
+
if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
|
|
1902
|
+
throw new Error(`channels.lifecycle not in "${serverId}"'s effective grant (§14.1)`);
|
|
1903
|
+
}
|
|
1904
|
+
const descriptor = entry.descriptor;
|
|
1905
|
+
const desired = this.getDesiredState(serverId, channelId);
|
|
1906
|
+
const open = desired === 'open' || desired === 'tuned-out';
|
|
1907
|
+
// Per-RPC timeouts do not bound a stream of promptly successful but
|
|
1908
|
+
// continually superseded receipts. Bound this whole operation, then
|
|
1909
|
+
// release its queue slot and surface the failure to delivery joiners.
|
|
1910
|
+
// Its open flag needs no change here: every retry follows a replaced
|
|
1911
|
+
// registration, a retarget (already invalidated, whether the old
|
|
1912
|
+
// target's RPC was answered or failed), or a receipt for this very
|
|
1913
|
+
// target.
|
|
1914
|
+
if (attempts >= MAX_CHANNEL_LIFECYCLE_ATTEMPTS) {
|
|
1915
|
+
const error = new ChannelLifecycleConvergenceError(`Channel ${channelId} did not converge after ${attempts} lifecycle attempts; its target or desired state kept changing`);
|
|
1916
|
+
this.emitTraceFn({
|
|
1917
|
+
type: 'mcpl:channel-reconcile-failed',
|
|
1918
|
+
serverId, channelId, desired, attempts, error: error.message,
|
|
1919
|
+
});
|
|
1920
|
+
throw error;
|
|
1921
|
+
}
|
|
1922
|
+
attempts++;
|
|
1923
|
+
const sameTarget = () => this.serverRegistry.getServer(serverId) === server &&
|
|
1924
|
+
sameChannelTarget(entry.descriptor, descriptor);
|
|
1925
|
+
let result;
|
|
1926
|
+
try {
|
|
1927
|
+
if (open) {
|
|
1928
|
+
result = await server.sendChannelsOpen({
|
|
1929
|
+
channelId, type: descriptor.type, address: descriptor.address,
|
|
1930
|
+
...(options?.history ? { history: options.history } : {}),
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
else {
|
|
1934
|
+
await server.sendChannelsClose({ channelId });
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
catch (error) {
|
|
1938
|
+
// An RPC can fail after the server acted, so a failure for the
|
|
1939
|
+
// current target leaves its outcome unknown: it is marked
|
|
1940
|
+
// unconfirmed with open unchanged, which keeps a confirmed-open
|
|
1941
|
+
// channel usable after a failed backscroll (the mark is read only
|
|
1942
|
+
// while not open). A failure from an older connection or target
|
|
1943
|
+
// also withdraws the open flag.
|
|
1944
|
+
if (this.channels.get(channelKey) === entry) {
|
|
1945
|
+
if (sameTarget())
|
|
1946
|
+
this.unconfirmedChannelTargets.add(entry);
|
|
1947
|
+
else
|
|
1948
|
+
this.invalidateTransportConfirmation(entry);
|
|
1949
|
+
}
|
|
1950
|
+
// A retarget while this RPC was out queued nothing for the new
|
|
1951
|
+
// target, since handleChanged leaves that to this operation. So a
|
|
1952
|
+
// failure for the old target says nothing about the new one, and a
|
|
1953
|
+
// channel still decided open or tuned out tries its new target,
|
|
1954
|
+
// within the attempt limit.
|
|
1955
|
+
if (this.channels.get(channelKey) === entry && !sameChannelTarget(entry.descriptor, descriptor)) {
|
|
1956
|
+
const now = this.getDesiredState(serverId, channelId);
|
|
1957
|
+
if (now === 'open' || now === 'tuned-out')
|
|
1958
|
+
continue;
|
|
1959
|
+
}
|
|
1960
|
+
throw error;
|
|
1961
|
+
}
|
|
1962
|
+
// A server can answer for a channel other than the one named: one
|
|
1963
|
+
// that falls back to its first channel of the type for an id it no
|
|
1964
|
+
// longer knows opens that channel and returns its history. Whatever
|
|
1965
|
+
// becomes of this receipt, the server did that: say so, and undo the
|
|
1966
|
+
// open it made if the host wants that channel closed.
|
|
1967
|
+
const substitute = open ? substitutedChannel(result, channelId) : undefined;
|
|
1968
|
+
if (substitute !== undefined) {
|
|
1969
|
+
// Each id prints through logValue, as the rejection lines' ids do,
|
|
1970
|
+
// so an odd one can't add a line or a field.
|
|
1971
|
+
const answered = logValue(substitute.kind === 'other' ? substitute.id : substitute.value);
|
|
1972
|
+
console.error(`[channel-open-substituted] server=${serverId} requested=${logValue(channelId)} answered=${answered}`);
|
|
1973
|
+
this.emitTraceFn({ type: 'mcpl:channel-open-substituted', serverId, channelId, answeredFor: answered });
|
|
1974
|
+
// A malformed id names nothing registered here, so there is
|
|
1975
|
+
// nothing to close.
|
|
1976
|
+
if (substitute.kind === 'other')
|
|
1977
|
+
this.closeSubstitutedChannel(serverId, substitute.id);
|
|
1978
|
+
}
|
|
1979
|
+
// Never apply an old receipt to the current target, even transiently:
|
|
1980
|
+
// a corrective open might fail before that stale bit is replaced.
|
|
1981
|
+
if (this.channels.get(channelKey) !== entry)
|
|
1982
|
+
continue;
|
|
1983
|
+
if (!sameTarget()) {
|
|
1984
|
+
this.invalidateTransportConfirmation(entry);
|
|
1985
|
+
continue;
|
|
1986
|
+
}
|
|
1987
|
+
// A current receipt for another channel confirms nothing about this
|
|
1988
|
+
// one, and its history is another channel's. So the open fails,
|
|
1989
|
+
// nothing in the answer is used, and this channel's open state is
|
|
1990
|
+
// unconfirmed: a server that doesn't resolve this id may not resolve
|
|
1991
|
+
// its sends either.
|
|
1992
|
+
if (substitute !== undefined) {
|
|
1993
|
+
this.invalidateTransportConfirmation(entry);
|
|
1994
|
+
throw new ChannelOpenSubstitutionError(`Server "${serverId}" answered channels/open for ${channelId} with ` +
|
|
1995
|
+
(substitute.kind === 'other' ? 'a different channel' : 'a channel id that is not a string') +
|
|
1996
|
+
`; ${channelId} is not marked open, and nothing in the answer is used`);
|
|
1997
|
+
}
|
|
1998
|
+
entry.open = open;
|
|
1999
|
+
this.unconfirmedChannelTargets.delete(entry);
|
|
2000
|
+
const now = this.getDesiredState(serverId, channelId);
|
|
2001
|
+
if (open !== (now === 'open' || now === 'tuned-out'))
|
|
2002
|
+
continue;
|
|
2003
|
+
return { open, result };
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
finally {
|
|
2007
|
+
// Synchronous with the operation's last look at its target: from here
|
|
2008
|
+
// on, nothing it does will open a new target.
|
|
2009
|
+
const left = (this.lifecycleOutstanding.get(lifecycleKey) ?? 1) - 1;
|
|
2010
|
+
if (left > 0)
|
|
2011
|
+
this.lifecycleOutstanding.set(lifecycleKey, left);
|
|
2012
|
+
else
|
|
2013
|
+
this.lifecycleOutstanding.delete(lifecycleKey);
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
/**
|
|
2017
|
+
* A server that answered an open with another channel really opened that
|
|
2018
|
+
* one. When it's registered here and the host wants it closed, close it
|
|
2019
|
+
* through its own lifecycle queue: otherwise its traffic would reach the
|
|
2020
|
+
* resident, marked open by its first incoming message. A channel the host
|
|
2021
|
+
* wants open is left alone, and a close can't be answered for another
|
|
2022
|
+
* channel, so this never starts another substitution. An id the host
|
|
2023
|
+
* doesn't know needs nothing: §14.5 refuses its traffic.
|
|
2024
|
+
*/
|
|
2025
|
+
closeSubstitutedChannel(serverId, channelId) {
|
|
2026
|
+
if (!this.channels.has(`${serverId}:${channelId}`))
|
|
2027
|
+
return;
|
|
2028
|
+
if (this.getDesiredState(serverId, channelId) !== 'closed')
|
|
2029
|
+
return;
|
|
2030
|
+
this.applyDesiredChannelState(serverId, channelId).catch((err) => {
|
|
2031
|
+
// Removal while queued is cancellation, as in reconcileChannels, and
|
|
2032
|
+
// exhausted convergence emitted its own diagnostic.
|
|
2033
|
+
if (!this.channels.has(`${serverId}:${channelId}`) ||
|
|
2034
|
+
err instanceof ChannelLifecycleCancelledError ||
|
|
2035
|
+
err instanceof ChannelLifecycleConvergenceError)
|
|
2036
|
+
return;
|
|
2037
|
+
this.emitTraceFn({
|
|
2038
|
+
type: 'mcpl:channel-reconcile-failed',
|
|
2039
|
+
serverId, channelId, desired: 'closed', error: err.message,
|
|
2040
|
+
});
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
2044
|
+
* Join outstanding lifecycle work without recording new intent. Retargeting
|
|
2045
|
+
* may have invalidated open confirmation while the decision remains pending.
|
|
2046
|
+
* A close that settles during the wait remains authoritative; waiting for
|
|
2047
|
+
* it is not an instruction to open the channel again.
|
|
2048
|
+
*/
|
|
2049
|
+
async waitForOpenChannel(entry) {
|
|
2050
|
+
const lifecycleKey = this.lifecycleKey(entry.serverId, entry.descriptor.id);
|
|
2051
|
+
for (;;) {
|
|
2052
|
+
const pending = this.channelLifecycleTails.get(lifecycleKey);
|
|
2053
|
+
if (!pending)
|
|
2054
|
+
break;
|
|
2055
|
+
try {
|
|
2056
|
+
await pending;
|
|
2057
|
+
}
|
|
2058
|
+
catch (error) {
|
|
2059
|
+
// A redundant backscroll failure can leave a known-open channel
|
|
2060
|
+
// usable. Exhausted convergence is different: it never established
|
|
2061
|
+
// the current target, so joined delivery must fail visibly.
|
|
2062
|
+
if (error instanceof ChannelLifecycleConvergenceError)
|
|
2063
|
+
throw error;
|
|
2064
|
+
}
|
|
2065
|
+
// A settled tail releases its own key. One still current after it
|
|
2066
|
+
// settled would be awaited again without ever yielding to the event
|
|
2067
|
+
// loop, so stop waiting rather than spin.
|
|
2068
|
+
if (this.channelLifecycleTails.get(lifecycleKey) === pending)
|
|
2069
|
+
break;
|
|
2070
|
+
}
|
|
2071
|
+
const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
|
|
2072
|
+
if (!current)
|
|
2073
|
+
throw new Error('Channel registration was removed while delivery waited');
|
|
2074
|
+
if (!current.open || this.getDesiredState(current.serverId, current.descriptor.id) === 'closed') {
|
|
2075
|
+
throw new Error('Channel is not confirmed open for delivery, or was selected for closure while delivery waited');
|
|
2076
|
+
}
|
|
2077
|
+
return current;
|
|
2078
|
+
}
|
|
1731
2079
|
async reconcileChannels(serverId, channels) {
|
|
1732
2080
|
const server = this.serverRegistry.getServer(serverId);
|
|
1733
2081
|
if (!server)
|
|
@@ -1748,57 +2096,17 @@ export class ChannelRegistry {
|
|
|
1748
2096
|
// announced to the agent as one batched notice after the loop, so a
|
|
1749
2097
|
// first boot on a new policy doesn't produce one notice per channel.
|
|
1750
2098
|
const policyOpened = [];
|
|
2099
|
+
// Record all admissions before waiting on any channel. A slow first RPC
|
|
2100
|
+
// must not delay intent for the rest of this registration batch.
|
|
1751
2101
|
for (const channel of channels) {
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
const key = `${serverId}:${channel.id}`;
|
|
1756
|
-
const desired = this.getDesiredState(serverId, channel.id);
|
|
1757
|
-
const entry = this.channels.get(key);
|
|
1758
|
-
// Tuned-out sits on the OPEN side of reconcile: traffic must keep
|
|
1759
|
-
// arriving (the subconscious reads it); only main's wake/visibility
|
|
1760
|
-
// is diverted, downstream at ingestion.
|
|
1761
|
-
if (desired !== 'open' && desired !== 'tuned-out') {
|
|
1762
|
-
try {
|
|
1763
|
-
await server.sendChannelsClose({ channelId: channel.id });
|
|
1764
|
-
if (entry)
|
|
1765
|
-
entry.open = false;
|
|
1766
|
-
}
|
|
1767
|
-
catch (err) {
|
|
1768
|
-
this.emitTraceFn({
|
|
1769
|
-
type: 'mcpl:channel-reconcile-failed',
|
|
1770
|
-
serverId,
|
|
1771
|
-
channelId: channel.id,
|
|
1772
|
-
desired,
|
|
1773
|
-
error: err.message,
|
|
1774
|
-
});
|
|
1775
|
-
}
|
|
1776
|
-
continue;
|
|
1777
|
-
}
|
|
1778
|
-
try {
|
|
1779
|
-
await server.sendChannelsOpen({
|
|
1780
|
-
channelId: channel.id,
|
|
1781
|
-
type: channel.type,
|
|
1782
|
-
address: channel.address,
|
|
1783
|
-
});
|
|
1784
|
-
if (entry)
|
|
1785
|
-
entry.open = true;
|
|
1786
|
-
}
|
|
1787
|
-
catch (err) {
|
|
1788
|
-
if (entry)
|
|
1789
|
-
entry.open = false;
|
|
1790
|
-
this.emitTraceFn({
|
|
1791
|
-
type: 'mcpl:channel-reconcile-failed',
|
|
1792
|
-
serverId,
|
|
1793
|
-
channelId: channel.id,
|
|
1794
|
-
desired,
|
|
1795
|
-
error: err.message,
|
|
1796
|
-
});
|
|
2102
|
+
const entry = this.channels.get(`${serverId}:${channel.id}`);
|
|
2103
|
+
if (entry && this.ensureInitialDesiredState(serverId, entry.descriptor)) {
|
|
2104
|
+
policyOpened.push({ channelId: channel.id, label: entry.descriptor.label });
|
|
1797
2105
|
}
|
|
1798
2106
|
}
|
|
1799
|
-
// Announce
|
|
1800
|
-
//
|
|
1801
|
-
//
|
|
2107
|
+
// Announce the durable admission decision before awaiting transport.
|
|
2108
|
+
// This is intent, not confirmation that an open RPC succeeded. It stays
|
|
2109
|
+
// truthful if the first attempt fails, and a retry needs no new admission.
|
|
1802
2110
|
if (policyOpened.length > 0) {
|
|
1803
2111
|
try {
|
|
1804
2112
|
this.onChannelAutoOpened?.({
|
|
@@ -1811,6 +2119,32 @@ export class ChannelRegistry {
|
|
|
1811
2119
|
console.error('onChannelAutoOpened (policy) failed:', err);
|
|
1812
2120
|
}
|
|
1813
2121
|
}
|
|
2122
|
+
// Each channel has its own lifecycle queue, so a held RPC for one (up to
|
|
2123
|
+
// its timeout, times its attempts) doesn't delay its siblings.
|
|
2124
|
+
await Promise.allSettled(channels.map(async (channel) => {
|
|
2125
|
+
if (!this.channels.has(`${serverId}:${channel.id}`))
|
|
2126
|
+
return;
|
|
2127
|
+
try {
|
|
2128
|
+
await this.applyDesiredChannelState(serverId, channel.id);
|
|
2129
|
+
}
|
|
2130
|
+
catch (err) {
|
|
2131
|
+
// Removal while queued is cancellation, not a failed subscription.
|
|
2132
|
+
// Exhausted convergence and a substituted answer already emitted
|
|
2133
|
+
// their own diagnostics.
|
|
2134
|
+
if (!this.channels.has(`${serverId}:${channel.id}`) ||
|
|
2135
|
+
err instanceof ChannelLifecycleCancelledError ||
|
|
2136
|
+
err instanceof ChannelLifecycleConvergenceError ||
|
|
2137
|
+
err instanceof ChannelOpenSubstitutionError)
|
|
2138
|
+
return;
|
|
2139
|
+
this.emitTraceFn({
|
|
2140
|
+
type: 'mcpl:channel-reconcile-failed',
|
|
2141
|
+
serverId,
|
|
2142
|
+
channelId: channel.id,
|
|
2143
|
+
desired: this.getDesiredState(serverId, channel.id),
|
|
2144
|
+
error: err.message,
|
|
2145
|
+
});
|
|
2146
|
+
}
|
|
2147
|
+
}));
|
|
1814
2148
|
}
|
|
1815
2149
|
// ==========================================================================
|
|
1816
2150
|
// Private: Typing notification
|
|
@@ -1907,33 +2241,35 @@ export class ChannelRegistry {
|
|
|
1907
2241
|
};
|
|
1908
2242
|
}
|
|
1909
2243
|
/**
|
|
1910
|
-
*
|
|
1911
|
-
*
|
|
1912
|
-
*
|
|
1913
|
-
*
|
|
1914
|
-
*
|
|
2244
|
+
* Record durable desired-open intent, then queue the channel's lifecycle
|
|
2245
|
+
* work (applyDesiredChannelState), which tells the server to subscribe and
|
|
2246
|
+
* marks the live entry open on a receipt for its current target. Every
|
|
2247
|
+
* open the agent or its speech asks for — the channel_open tool, delivery
|
|
2248
|
+
* into a closed locus, an explicit send into a closed channel — comes
|
|
2249
|
+
* here. Reconciliation queues that work directly, as the closes do
|
|
2250
|
+
* (channel_close, a decline, and the close of a substituted channel).
|
|
1915
2251
|
* Desired state is recorded BEFORE the server round-trip: intent sticks
|
|
1916
2252
|
* even if the subscribe fails, and reconciliation retries later.
|
|
2253
|
+
* Automatic delivery needs open transport, not a change of attention:
|
|
2254
|
+
* preserve tune-out epochs both before and during the round-trip.
|
|
2255
|
+
*
|
|
2256
|
+
* `opened` says whether this call turned a closed channel open, which is
|
|
2257
|
+
* what the delivery and reply notices tell the resident. Reconfirming the
|
|
2258
|
+
* transport of a channel already decided open or tuned out isn't one.
|
|
1917
2259
|
*/
|
|
1918
2260
|
async openChannelNow(entry, source, history) {
|
|
1919
|
-
|
|
1920
|
-
const
|
|
1921
|
-
|
|
1922
|
-
|
|
2261
|
+
const transportOnly = source !== 'agent-tool';
|
|
2262
|
+
const before = this.getDesiredState(entry.serverId, entry.descriptor.id);
|
|
2263
|
+
const opened = before !== 'open' && before !== 'tuned-out';
|
|
2264
|
+
if (!transportOnly || before !== 'tuned-out') {
|
|
2265
|
+
this.setDesiredState(entry.serverId, entry.descriptor.id, 'open', source);
|
|
1923
2266
|
}
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
throw new Error(
|
|
2267
|
+
const applied = await this.applyDesiredChannelState(entry.serverId, entry.descriptor.id, { history });
|
|
2268
|
+
const desired = this.getDesiredState(entry.serverId, entry.descriptor.id);
|
|
2269
|
+
if (!applied.open || (desired !== 'open' && !(transportOnly && desired === 'tuned-out'))) {
|
|
2270
|
+
throw new Error('Channel open was superseded by a newer lifecycle decision');
|
|
1928
2271
|
}
|
|
1929
|
-
|
|
1930
|
-
channelId: entry.descriptor.id,
|
|
1931
|
-
type: entry.descriptor.type,
|
|
1932
|
-
address: entry.descriptor.address,
|
|
1933
|
-
...(history ? { history } : {}),
|
|
1934
|
-
});
|
|
1935
|
-
entry.open = true;
|
|
1936
|
-
return result;
|
|
2272
|
+
return { result: applied.result, opened };
|
|
1937
2273
|
}
|
|
1938
2274
|
/**
|
|
1939
2275
|
* Resolve a `>>` prose-routing target (explicit prose routing —
|
|
@@ -2119,19 +2455,25 @@ export class ChannelRegistry {
|
|
|
2119
2455
|
return { status: 'unknown-channel' };
|
|
2120
2456
|
if (matches.length > 1)
|
|
2121
2457
|
return { status: 'ambiguous' };
|
|
2122
|
-
|
|
2123
|
-
const resolved = { channelId: entry.descriptor.id, label: entry.descriptor.label };
|
|
2124
|
-
if (entry.open && this.getDesiredState(entry.serverId, entry.descriptor.id) === 'open') {
|
|
2125
|
-
return { status: 'already-open', ...resolved };
|
|
2126
|
-
}
|
|
2458
|
+
let entry = matches[0];
|
|
2127
2459
|
try {
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2460
|
+
if (this.hasPendingLifecycle(entry)) {
|
|
2461
|
+
entry = await this.waitForOpenChannel(entry);
|
|
2462
|
+
}
|
|
2463
|
+
const desired = this.getDesiredState(entry.serverId, entry.descriptor.id);
|
|
2464
|
+
if (entry.open && (desired === 'open' || desired === 'tuned-out')) {
|
|
2465
|
+
return { status: 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
|
|
2466
|
+
}
|
|
2467
|
+
const { opened } = await this.openChannelNow(entry, 'opened-by-reply');
|
|
2468
|
+
entry = await this.waitForOpenChannel(entry);
|
|
2469
|
+
if (opened) {
|
|
2470
|
+
this.emitTraceFn({
|
|
2471
|
+
type: 'mcpl:channel-opened-by-send',
|
|
2472
|
+
serverId: entry.serverId,
|
|
2473
|
+
channelId: entry.descriptor.id,
|
|
2474
|
+
});
|
|
2475
|
+
}
|
|
2476
|
+
return { status: opened ? 'opened' : 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
|
|
2135
2477
|
}
|
|
2136
2478
|
catch (err) {
|
|
2137
2479
|
this.emitTraceFn({
|
|
@@ -2140,7 +2482,9 @@ export class ChannelRegistry {
|
|
|
2140
2482
|
channelId: entry.descriptor.id,
|
|
2141
2483
|
error: err.message,
|
|
2142
2484
|
});
|
|
2143
|
-
|
|
2485
|
+
const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
|
|
2486
|
+
return { status: 'open-failed', channelId: entry.descriptor.id,
|
|
2487
|
+
...(current ? { label: current.descriptor.label } : {}) };
|
|
2144
2488
|
}
|
|
2145
2489
|
}
|
|
2146
2490
|
async handleToolOpen(input) {
|
|
@@ -2170,7 +2514,7 @@ export class ChannelRegistry {
|
|
|
2170
2514
|
};
|
|
2171
2515
|
}
|
|
2172
2516
|
const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open';
|
|
2173
|
-
if (entry.open && alreadyDesiredOpen && !input.backscroll) {
|
|
2517
|
+
if (entry.open && alreadyDesiredOpen && !input.backscroll && !this.hasPendingLifecycle(entry)) {
|
|
2174
2518
|
return {
|
|
2175
2519
|
success: true,
|
|
2176
2520
|
data: { channelId: input.channelId, status: 'already open' },
|
|
@@ -2183,7 +2527,7 @@ export class ChannelRegistry {
|
|
|
2183
2527
|
...(input.beforeMessageId ? { beforeMessageId: input.beforeMessageId } : {}),
|
|
2184
2528
|
}
|
|
2185
2529
|
: undefined;
|
|
2186
|
-
const result = await this.openChannelNow(entry, 'agent-tool', history);
|
|
2530
|
+
const { result } = await this.openChannelNow(entry, 'agent-tool', history);
|
|
2187
2531
|
return {
|
|
2188
2532
|
success: true,
|
|
2189
2533
|
data: {
|
|
@@ -2195,9 +2539,18 @@ export class ChannelRegistry {
|
|
|
2195
2539
|
};
|
|
2196
2540
|
}
|
|
2197
2541
|
catch (err) {
|
|
2542
|
+
// The decision was recorded before the round-trip and outlives its
|
|
2543
|
+
// failure. Say so: otherwise a later delivery that opens the channel
|
|
2544
|
+
// would be the resident's first sign that it was still wanted open.
|
|
2545
|
+
const current = this.channels.get(`${entry.serverId}:${input.channelId}`);
|
|
2546
|
+
const stillSelected = current !== undefined && !current.open &&
|
|
2547
|
+
this.getDesiredState(entry.serverId, input.channelId) === 'open';
|
|
2198
2548
|
return {
|
|
2199
2549
|
success: false,
|
|
2200
|
-
error: `Failed to open channel: ${err.message}
|
|
2550
|
+
error: `Failed to open channel: ${err.message}` +
|
|
2551
|
+
(stillSelected
|
|
2552
|
+
? ' (it stays selected for opening: a later delivery or reconnect tries again, and channel_close withdraws that)'
|
|
2553
|
+
: ''),
|
|
2201
2554
|
isError: true,
|
|
2202
2555
|
};
|
|
2203
2556
|
}
|
|
@@ -2213,7 +2566,8 @@ export class ChannelRegistry {
|
|
|
2213
2566
|
};
|
|
2214
2567
|
}
|
|
2215
2568
|
const alreadyDesiredClosed = this.getDesiredState(entry.serverId, input.channelId) === 'closed';
|
|
2216
|
-
if (!entry.open && alreadyDesiredClosed)
|
|
2569
|
+
if (!entry.open && alreadyDesiredClosed && !this.hasPendingLifecycle(entry) &&
|
|
2570
|
+
!this.unconfirmedChannelTargets.has(entry)) {
|
|
2217
2571
|
return {
|
|
2218
2572
|
success: true,
|
|
2219
2573
|
data: { channelId: input.channelId, status: 'already closed' },
|
|
@@ -2275,8 +2629,10 @@ export class ChannelRegistry {
|
|
|
2275
2629
|
isError: true,
|
|
2276
2630
|
};
|
|
2277
2631
|
}
|
|
2278
|
-
await
|
|
2279
|
-
|
|
2632
|
+
const applied = await this.applyDesiredChannelState(entry.serverId, input.channelId);
|
|
2633
|
+
if (applied.open || this.getDesiredState(entry.serverId, input.channelId) !== 'closed') {
|
|
2634
|
+
throw new Error('Channel close was superseded by a newer lifecycle decision');
|
|
2635
|
+
}
|
|
2280
2636
|
return {
|
|
2281
2637
|
success: true,
|
|
2282
2638
|
data: { channelId: input.channelId, status: 'closed' },
|
|
@@ -2337,6 +2693,24 @@ export class ChannelRegistry {
|
|
|
2337
2693
|
}
|
|
2338
2694
|
}
|
|
2339
2695
|
}
|
|
2696
|
+
// Where the host directs the lifecycle, a decline goes through the
|
|
2697
|
+
// lifecycle queue like any closed decision, unless a receipt confirmed
|
|
2698
|
+
// the transport closed with nothing pending: an unconfirmed transport may
|
|
2699
|
+
// be open, and pending work must settle on this decision. A failed close
|
|
2700
|
+
// is reported; the decision stands.
|
|
2701
|
+
let closeError;
|
|
2702
|
+
const current = this.channels.get(`${entry.serverId}:${input.channelId}`);
|
|
2703
|
+
if (current && this.directsLifecycle(entry.serverId) &&
|
|
2704
|
+
(this.unconfirmedChannelTargets.has(current) || this.hasPendingLifecycle(current))) {
|
|
2705
|
+
try {
|
|
2706
|
+
const applied = await this.applyDesiredChannelState(entry.serverId, input.channelId);
|
|
2707
|
+
if (applied.open)
|
|
2708
|
+
closeError = 'the close was superseded by a newer lifecycle decision';
|
|
2709
|
+
}
|
|
2710
|
+
catch (error) {
|
|
2711
|
+
closeError = error.message;
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2340
2714
|
this.appendLifecycleEvent({
|
|
2341
2715
|
kind: 'invitation-declined',
|
|
2342
2716
|
serverId: entry.serverId,
|
|
@@ -2353,6 +2727,7 @@ export class ChannelRegistry {
|
|
|
2353
2727
|
acknowledged,
|
|
2354
2728
|
...(representation ? { representation } : {}),
|
|
2355
2729
|
...(acknowledgmentError ? { acknowledgmentError } : {}),
|
|
2730
|
+
...(closeError ? { closeError: `Failed to close the channel's transport: ${closeError}` } : {}),
|
|
2356
2731
|
},
|
|
2357
2732
|
};
|
|
2358
2733
|
}
|
|
@@ -2360,7 +2735,8 @@ export class ChannelRegistry {
|
|
|
2360
2735
|
* Handle the synthesized `think` tool — a private reasoning scratchpad. It
|
|
2361
2736
|
* sends nothing, and (unlike before) does NOT silence the turn: trailing
|
|
2362
2737
|
* plain text is still routed as the reply. The thought stays in the agent's
|
|
2363
|
-
* own context/chronicle. To deliberately not reply,
|
|
2738
|
+
* own context/chronicle. To deliberately not reply, an agent that may call
|
|
2739
|
+
* skip_reply uses it.
|
|
2364
2740
|
*/
|
|
2365
2741
|
handleToolThink(input) {
|
|
2366
2742
|
return {
|
|
@@ -2369,9 +2745,11 @@ export class ChannelRegistry {
|
|
|
2369
2745
|
// tool_use block; don't duplicate it in the result.
|
|
2370
2746
|
data: {
|
|
2371
2747
|
noted: true,
|
|
2748
|
+
// No tool to end the turn with: this handler can't see whether the
|
|
2749
|
+
// caller may call skip_reply, and think's own description says so
|
|
2750
|
+
// where it may.
|
|
2372
2751
|
note: 'Thought recorded (private — not sent anywhere). Same-round text routing depends on ' +
|
|
2373
|
-
'your current same_round_think_text_policy; use agent_settings get to inspect it,
|
|
2374
|
-
'call skip_reply to end the turn without replying.',
|
|
2752
|
+
'your current same_round_think_text_policy; use agent_settings get to inspect it.',
|
|
2375
2753
|
},
|
|
2376
2754
|
};
|
|
2377
2755
|
}
|
|
@@ -2495,7 +2873,11 @@ export class ChannelRegistry {
|
|
|
2495
2873
|
}
|
|
2496
2874
|
resolveLocus(conversationId) {
|
|
2497
2875
|
const home = this.homeChannelResolver?.(conversationId);
|
|
2498
|
-
return home ?? this.activeChannelResolver?.(conversationId) ?? this.
|
|
2876
|
+
return home ?? this.activeChannelResolver?.(conversationId) ?? this.globalFallbackFor(conversationId);
|
|
2877
|
+
}
|
|
2878
|
+
/** The process-global fallback channel for this conversation, or null when it takes none (takesGlobalFallback). */
|
|
2879
|
+
globalFallbackFor(conversationId) {
|
|
2880
|
+
return this.takesGlobalFallback?.(conversationId) === false ? null : this.defaultPublishChannel;
|
|
2499
2881
|
}
|
|
2500
2882
|
/**
|
|
2501
2883
|
* Resolve a publish target to exactly one registered channel: `serverId`
|
|
@@ -2573,7 +2955,7 @@ export class ChannelRegistry {
|
|
|
2573
2955
|
const found = this.findExactEntry(target);
|
|
2574
2956
|
if ('error' in found)
|
|
2575
2957
|
return { status: 'failed', reason: found.error, at: at() };
|
|
2576
|
-
|
|
2958
|
+
let entry = found.entry;
|
|
2577
2959
|
const destination = {
|
|
2578
2960
|
serverId: entry.serverId,
|
|
2579
2961
|
channelId: entry.descriptor.id,
|
|
@@ -2595,28 +2977,59 @@ export class ChannelRegistry {
|
|
|
2595
2977
|
at: at(),
|
|
2596
2978
|
};
|
|
2597
2979
|
}
|
|
2598
|
-
|
|
2980
|
+
// Lifecycle work already queued for the channel settles first: words go
|
|
2981
|
+
// out only into the channel it leaves, and fail visibly if it leaves
|
|
2982
|
+
// none open.
|
|
2983
|
+
if (this.hasPendingLifecycle(entry)) {
|
|
2599
2984
|
try {
|
|
2600
|
-
await this.
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
}
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2985
|
+
entry = await this.waitForOpenChannel(entry);
|
|
2986
|
+
}
|
|
2987
|
+
catch (err) {
|
|
2988
|
+
return {
|
|
2989
|
+
status: 'failed',
|
|
2990
|
+
destination,
|
|
2991
|
+
reason: `lifecycle work prevented delivery: ${connectorText(err instanceof Error ? err.message : String(err))}`,
|
|
2992
|
+
at: at(),
|
|
2993
|
+
};
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
// INVARIANT: it is not possible to send into a closed channel. A send
|
|
2997
|
+
// into a closed-but-registered channel (the canonical case: a DM that
|
|
2998
|
+
// woke the agent, since DMs register closed) OPENS it first, so typing
|
|
2999
|
+
// indicators, reaction machinery and inbound forwarding come alive with
|
|
3000
|
+
// the words. If the open fails, nothing goes out: a half-alive delivery
|
|
3001
|
+
// is worse than a loud marker. A channel decided closed is engaged even
|
|
3002
|
+
// while its transport is still open (a close that failed), as a reply
|
|
3003
|
+
// would engage it, wherever an open can succeed. Without the lifecycle
|
|
3004
|
+
// grant it can't, and a send opens before it publishes, so there a
|
|
3005
|
+
// channel whose transport is open keeps publishing.
|
|
3006
|
+
const engagesDecidedClosed = this.getDesiredState(entry.serverId, entry.descriptor.id) === 'closed' &&
|
|
3007
|
+
CapabilityGrant.of(server).has('channels.lifecycle');
|
|
3008
|
+
if (!entry.open || engagesDecidedClosed) {
|
|
3009
|
+
try {
|
|
3010
|
+
const { opened } = await this.openChannelNow(entry, openSource);
|
|
3011
|
+
entry = await this.waitForOpenChannel(entry);
|
|
3012
|
+
if (opened) {
|
|
3013
|
+
if (target.routed) {
|
|
3014
|
+
console.error(`[routeSpeech] ${conversationId}: locus ${logValue(entry.descriptor.id)} was closed — opened by delivery (speech implies engagement)`);
|
|
2617
3015
|
}
|
|
2618
|
-
|
|
2619
|
-
|
|
3016
|
+
this.emitTraceFn({
|
|
3017
|
+
type: 'mcpl:channel-opened-by-send',
|
|
3018
|
+
serverId: entry.serverId,
|
|
3019
|
+
channelId: entry.descriptor.id,
|
|
3020
|
+
});
|
|
3021
|
+
if (openSource === 'opened-by-delivery') {
|
|
3022
|
+
try {
|
|
3023
|
+
this.onChannelAutoOpened?.({
|
|
3024
|
+
conversationId,
|
|
3025
|
+
serverId: entry.serverId,
|
|
3026
|
+
source: 'opened-by-delivery',
|
|
3027
|
+
channels: [{ channelId: entry.descriptor.id, label: entry.descriptor.label }],
|
|
3028
|
+
});
|
|
3029
|
+
}
|
|
3030
|
+
catch (err) {
|
|
3031
|
+
console.error('onChannelAutoOpened (delivery) failed:', err);
|
|
3032
|
+
}
|
|
2620
3033
|
}
|
|
2621
3034
|
}
|
|
2622
3035
|
}
|
|
@@ -2646,7 +3059,9 @@ export class ChannelRegistry {
|
|
|
2646
3059
|
* Plain speech for one turn segment: publish `text` to the turn's frozen
|
|
2647
3060
|
* locus and report the PublishOutcome. A failure (or an uncertain outcome)
|
|
2648
3061
|
* is surfaced as a trace and through onRouteFailure, so the agent learns
|
|
2649
|
-
* its words did not (or may not have) reached anyone.
|
|
3062
|
+
* its words did not (or may not have) reached anyone. An ephemeral run's
|
|
3063
|
+
* speech with no locus is the exception: it is its caller's result, so it
|
|
3064
|
+
* returns as not delivered and surfaces nothing.
|
|
2650
3065
|
*/
|
|
2651
3066
|
async deliverSpeech(conversationId, text,
|
|
2652
3067
|
/** The turn's FROZEN locus, snapshotted once by the caller (resolveLocus
|
|
@@ -2655,7 +3070,9 @@ export class ChannelRegistry {
|
|
|
2655
3070
|
* dispatch resolving live can read the NEXT turn's trigger state or a
|
|
2656
3071
|
* stale global default and land the reply in the wrong channel (item-3,
|
|
2657
3072
|
* PR #32, and the 2026-07-22 Sol DM misroute). Explicit `null` means
|
|
2658
|
-
* "this turn is pinned to no locus": fail loudly rather than guess
|
|
3073
|
+
* "this turn is pinned to no locus": fail loudly rather than guess,
|
|
3074
|
+
* except for an ephemeral run, whose speech is then its caller's
|
|
3075
|
+
* result. */
|
|
2659
3076
|
locusChannelId,
|
|
2660
3077
|
/** `heldOnFailure`: the caller holds a `failed` delivery's words as a
|
|
2661
3078
|
* draft with its own notice, so no failure marker is written for it.
|
|
@@ -2688,6 +3105,12 @@ export class ChannelRegistry {
|
|
|
2688
3105
|
return fail(null, reason, { status: 'failed', reason, at: Date.now() });
|
|
2689
3106
|
}
|
|
2690
3107
|
if (!locusChannelId) {
|
|
3108
|
+
// An ephemeral run with no channel of its own: its plain speech is its
|
|
3109
|
+
// caller's result (takesGlobalFallback). It returns as not delivered,
|
|
3110
|
+
// with its own reason, and writes no line, trace or notice.
|
|
3111
|
+
if (this.takesGlobalFallback?.(conversationId) === false) {
|
|
3112
|
+
return { status: 'failed', reason: "an ephemeral run's plain speech with no channel of its own is its caller's result", at: Date.now() };
|
|
3113
|
+
}
|
|
2691
3114
|
// The turn froze with no locus (no home, no triggering channel, no
|
|
2692
3115
|
// global inbound ever seen) — the agent was told its prose stays in
|
|
2693
3116
|
// the archive; honor that.
|
|
@@ -2726,7 +3149,7 @@ export class ChannelRegistry {
|
|
|
2726
3149
|
...(outcome.messageId !== undefined ? { messageId: outcome.messageId } : {}),
|
|
2727
3150
|
};
|
|
2728
3151
|
}
|
|
2729
|
-
async handleToolPublish(input) {
|
|
3152
|
+
async handleToolPublish(input, origin) {
|
|
2730
3153
|
// Resolve content: accept both `content` and `text` (backward compat)
|
|
2731
3154
|
const messageText = input.content ?? input.text;
|
|
2732
3155
|
if (!messageText) {
|
|
@@ -2736,12 +3159,17 @@ export class ChannelRegistry {
|
|
|
2736
3159
|
isError: true,
|
|
2737
3160
|
};
|
|
2738
3161
|
}
|
|
2739
|
-
// Resolve channelId: default to most recent incoming channel
|
|
2740
|
-
|
|
3162
|
+
// Resolve channelId: default to the most recent incoming channel, which an
|
|
3163
|
+
// ephemeral run never takes (takesGlobalFallback), so it names one.
|
|
3164
|
+
const caller = origin?.kind === 'agent' ? origin.agentName : undefined;
|
|
3165
|
+
const channelId = input.channelId ?? (caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel);
|
|
2741
3166
|
if (!channelId) {
|
|
2742
3167
|
return {
|
|
2743
3168
|
success: false,
|
|
2744
|
-
error:
|
|
3169
|
+
error: caller !== undefined && this.takesGlobalFallback?.(caller) === false
|
|
3170
|
+
? 'Nothing was published: name the channel with channelId. A subagent has no default channel, since its plain '
|
|
3171
|
+
+ 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.'
|
|
3172
|
+
: 'No channelId specified and no default channel available',
|
|
2745
3173
|
isError: true,
|
|
2746
3174
|
};
|
|
2747
3175
|
}
|