@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
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import type { ContentBlock } from '@animalabs/membrane';
|
|
17
17
|
import { convertBlock } from './convert-block.js';
|
|
18
18
|
import type { JsStore } from '@animalabs/chronicle';
|
|
19
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
19
20
|
import { ensureStateRegistered } from '../module-registry.js';
|
|
20
21
|
|
|
21
22
|
import type {
|
|
@@ -51,8 +52,42 @@ import { readSkipReplyWake } from './skip-reply-wake.js';
|
|
|
51
52
|
// ============================================================================
|
|
52
53
|
|
|
53
54
|
const TYPING_INTERVAL_MS = 7_000;
|
|
55
|
+
const MAX_CHANNEL_LIFECYCLE_ATTEMPTS = 5;
|
|
56
|
+
|
|
57
|
+
/** A successful RPC can still fail to establish the latest channel target. */
|
|
58
|
+
class ChannelLifecycleConvergenceError extends Error {}
|
|
59
|
+
/** A channels/open answered for a channel other than the one it named. */
|
|
60
|
+
class ChannelOpenSubstitutionError extends Error {}
|
|
61
|
+
/** An operation queued for a server's registrations that were removed. */
|
|
62
|
+
class ChannelLifecycleCancelledError extends Error {}
|
|
54
63
|
const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle';
|
|
55
64
|
|
|
65
|
+
/**
|
|
66
|
+
* What a channels/open answer says about which channel the server opened:
|
|
67
|
+
* its `channel.id`, or else the `channelId` of a history item. Each is
|
|
68
|
+
* evidence. A string other than the requested id names another channel (ids
|
|
69
|
+
* compare exactly, as the server's traffic is matched). Null or absence names
|
|
70
|
+
* no channel, and gives undefined, as does an answer that names none at all.
|
|
71
|
+
* Any other value is malformed: it names nothing the host could register.
|
|
72
|
+
*/
|
|
73
|
+
function substitutedChannel(
|
|
74
|
+
result: ChannelsOpenResult | undefined,
|
|
75
|
+
requested: string,
|
|
76
|
+
): { kind: 'other'; id: string } | { kind: 'malformed'; value: unknown } | undefined {
|
|
77
|
+
const named = (value: unknown) => {
|
|
78
|
+
if (value === undefined || value === null || value === requested) return undefined;
|
|
79
|
+
return typeof value === 'string' ? { kind: 'other' as const, id: value } : { kind: 'malformed' as const, value };
|
|
80
|
+
};
|
|
81
|
+
const answered = named(result?.channel?.id);
|
|
82
|
+
if (answered) return answered;
|
|
83
|
+
const history: unknown[] = Array.isArray(result?.history) ? result.history : [];
|
|
84
|
+
for (const item of history) {
|
|
85
|
+
const from = named((item as { channelId?: unknown } | null)?.channelId);
|
|
86
|
+
if (from) return from;
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
56
91
|
/**
|
|
57
92
|
* Durable "which label did this channelId have, each time we saw it" log.
|
|
58
93
|
* `resolveProseTarget()` resolves a label to an id only for channels
|
|
@@ -213,9 +248,14 @@ function shallowEqualRecord(
|
|
|
213
248
|
interface ChannelEntry {
|
|
214
249
|
serverId: string;
|
|
215
250
|
descriptor: ChannelDescriptor;
|
|
251
|
+
/** Whether the current target is confirmed open; false can be unconfirmed. */
|
|
216
252
|
open: boolean;
|
|
217
253
|
}
|
|
218
254
|
|
|
255
|
+
function sameChannelTarget(a: ChannelDescriptor, b: ChannelDescriptor): boolean {
|
|
256
|
+
return a.type === b.type && isDeepStrictEqual(a.address, b.address);
|
|
257
|
+
}
|
|
258
|
+
|
|
219
259
|
/** Minimal responder interface for sending JSON-RPC results back. */
|
|
220
260
|
interface Responder {
|
|
221
261
|
respond(result: unknown): void;
|
|
@@ -252,6 +292,20 @@ interface McplChannelIncomingEvent {
|
|
|
252
292
|
// Channel Tool Definitions
|
|
253
293
|
// ============================================================================
|
|
254
294
|
|
|
295
|
+
/**
|
|
296
|
+
* `journal`'s description. It tells the model to call `skip_reply` to end the
|
|
297
|
+
* turn only where the agent may call it: the framework builds each agent's
|
|
298
|
+
* surface with that agent's permission, and a refused `skip_reply` leaves the
|
|
299
|
+
* turn going on.
|
|
300
|
+
*/
|
|
301
|
+
export function journalToolDescription(canSkipReply: boolean): string {
|
|
302
|
+
return 'Write an entry in your private journal. The entry stays in your own context and ' +
|
|
303
|
+
'memory and is NOT sent to any channel, surface or person. Use it for anything longer ' +
|
|
304
|
+
'than a line that you want to keep for yourself — reflections, what you decided and ' +
|
|
305
|
+
'why, notes for later. It does not end your turn and does not affect where ordinary ' +
|
|
306
|
+
'text is routed' + (canSkipReply ? '; to end the turn without replying, call skip_reply afterwards.' : '.');
|
|
307
|
+
}
|
|
308
|
+
|
|
255
309
|
const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
256
310
|
{
|
|
257
311
|
name: 'channel_list',
|
|
@@ -316,11 +370,11 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
316
370
|
},
|
|
317
371
|
{
|
|
318
372
|
name: 'channel_publish',
|
|
319
|
-
description: 'Publish a message to a channel. If channelId is omitted, publishes to the most recent incoming channel.',
|
|
373
|
+
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.',
|
|
320
374
|
inputSchema: {
|
|
321
375
|
type: 'object' as const,
|
|
322
376
|
properties: {
|
|
323
|
-
channelId: { type: 'string', description: 'ID of the channel to publish to (defaults to the most recent incoming channel)' },
|
|
377
|
+
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)' },
|
|
324
378
|
content: { type: 'string', description: 'Text content to publish' },
|
|
325
379
|
text: { type: 'string', description: 'Alias for content' },
|
|
326
380
|
},
|
|
@@ -329,6 +383,9 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
329
383
|
},
|
|
330
384
|
{
|
|
331
385
|
name: 'think',
|
|
386
|
+
// No agent's surface shows this description: getToolsForAgent gives every
|
|
387
|
+
// agent its own think (buildThinkTool), which names skip_reply only where
|
|
388
|
+
// that agent may call it.
|
|
332
389
|
description:
|
|
333
390
|
'Reason privately. The content stays in your own context and is NOT sent to any ' +
|
|
334
391
|
'channel or surface. Same-round routing of ordinary text beside think() depends on ' +
|
|
@@ -348,12 +405,7 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
348
405
|
},
|
|
349
406
|
{
|
|
350
407
|
name: 'journal',
|
|
351
|
-
description:
|
|
352
|
-
'Write an entry in your private journal. The entry stays in your own context and ' +
|
|
353
|
-
'memory and is NOT sent to any channel, surface or person. Use it for anything longer ' +
|
|
354
|
-
'than a line that you want to keep for yourself — reflections, what you decided and ' +
|
|
355
|
-
'why, notes for later. It does not end your turn and does not affect where ordinary ' +
|
|
356
|
-
'text is routed; to end the turn without replying, call skip_reply afterwards.',
|
|
408
|
+
description: journalToolDescription(true),
|
|
357
409
|
inputSchema: {
|
|
358
410
|
type: 'object' as const,
|
|
359
411
|
properties: {
|
|
@@ -457,7 +509,12 @@ export interface PublishOutcome {
|
|
|
457
509
|
* a failed request this way.
|
|
458
510
|
*/
|
|
459
511
|
function publishErrorOutcome(err: unknown, destination: PublishDestination, at: number): PublishOutcome {
|
|
460
|
-
|
|
512
|
+
// This host's own words for a request that wasn't sent or wasn't answered
|
|
513
|
+
// are sentences, and every composition of the reason adds its own ending,
|
|
514
|
+
// so one final period comes off them. A server's error answer keeps its
|
|
515
|
+
// words as they came.
|
|
516
|
+
const text = connectorText(err instanceof Error ? err.message : String(err));
|
|
517
|
+
const message = err instanceof McplRequestError && err.outcome !== 'error-response' ? text.replace(/(?<!\.)\.$/, '') : text;
|
|
461
518
|
const notSent = err instanceof McplRequestError && err.outcome === 'not-sent';
|
|
462
519
|
const detail = err instanceof McplRequestError ? err.data : undefined;
|
|
463
520
|
return {
|
|
@@ -534,8 +591,10 @@ interface ChannelRegistryOptions {
|
|
|
534
591
|
/**
|
|
535
592
|
* Called when a text-only turn's speech could NOT be delivered to its
|
|
536
593
|
* conversational locus — no locus, an unregistered channel, a missing
|
|
537
|
-
* server, or the server reporting `delivered: false`.
|
|
538
|
-
*
|
|
594
|
+
* server, or the server reporting `delivered: false`. Not called for an
|
|
595
|
+
* ephemeral run's speech with no locus, which is its caller's result
|
|
596
|
+
* (`takesGlobalFallback`). The host wires this to drop a
|
|
597
|
+
* `[discord-send-failed]` marker into chronicle so the failure is
|
|
539
598
|
* visible to the agent (and operator) rather than silently lost. Must not
|
|
540
599
|
* itself trigger inference (avoid wake loops).
|
|
541
600
|
*/
|
|
@@ -551,14 +610,15 @@ interface ChannelRegistryOptions {
|
|
|
551
610
|
detail?: string;
|
|
552
611
|
}) => void;
|
|
553
612
|
/**
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
* re-
|
|
613
|
+
* Announces either a durable subscription-policy admission (before its
|
|
614
|
+
* transport RPC; not an open confirmation), or a delivery that turned a
|
|
615
|
+
* closed channel open, once its transport is confirmed. Reconfirming the
|
|
616
|
+
* transport of a channel already decided open or tuned out isn't one.
|
|
617
|
+
* The host records a durable, non-waking notice. What an open channel
|
|
618
|
+
* delivers is the server's to say (#406).
|
|
619
|
+
* Policy admissions fire once per channel: the persisted desired-state
|
|
620
|
+
* decision prevents re-announcement on retry or reboot. channel_close
|
|
621
|
+
* supersedes policy; a fresh delivery into a closed locus engages it again.
|
|
562
622
|
*/
|
|
563
623
|
onChannelAutoOpened?: (info: {
|
|
564
624
|
/** Agent whose action caused the open (delivery); absent for policy opens. */
|
|
@@ -594,6 +654,18 @@ interface ChannelRegistryOptions {
|
|
|
594
654
|
* `defaultPublishChannel`.
|
|
595
655
|
*/
|
|
596
656
|
activeChannelResolver?: (agentName: string) => string | undefined;
|
|
657
|
+
/**
|
|
658
|
+
* Whether a conversation takes the process-global fallback, the channel of
|
|
659
|
+
* the most recent delivered inbound message: as where its plain speech lands
|
|
660
|
+
* when it has no home and no channel of its own this turn, as its channel
|
|
661
|
+
* context's `incoming` and default outgoing channel, and as
|
|
662
|
+
* `channel_publish`'s target when it names none. False for an ephemeral run
|
|
663
|
+
* (a subagent): its prose is its caller's result, so a channel that only the
|
|
664
|
+
* latest inbound message picked is never its destination, and its plain
|
|
665
|
+
* speech with no channel of its own is undelivered rather than a failed
|
|
666
|
+
* delivery. Absent, every conversation takes it.
|
|
667
|
+
*/
|
|
668
|
+
takesGlobalFallback?: (conversationId: string) => boolean;
|
|
597
669
|
/** Whether this framework has EventGate and can honor skip_reply self-wakes. */
|
|
598
670
|
eventGatePresent?: boolean;
|
|
599
671
|
}
|
|
@@ -638,6 +710,7 @@ export class ChannelRegistry {
|
|
|
638
710
|
}) => void;
|
|
639
711
|
private homeChannelResolver?: (agentName: string) => string | undefined;
|
|
640
712
|
private activeChannelResolver?: (agentName: string) => string | undefined;
|
|
713
|
+
private takesGlobalFallback?: (conversationId: string) => boolean;
|
|
641
714
|
private store?: JsStore;
|
|
642
715
|
|
|
643
716
|
/** Registered channels, keyed by `{serverId}:{channelId}`. */
|
|
@@ -727,6 +800,46 @@ export class ChannelRegistry {
|
|
|
727
800
|
/** One-time migration inputs from the retired recipe auto-open policy. */
|
|
728
801
|
private legacyPolicies = new Map<string, 'auto' | 'manual' | string[]>();
|
|
729
802
|
private migratedLegacyPolicies = new Set<string>();
|
|
803
|
+
|
|
804
|
+
// Keep one transport operation in flight per logical channel, including
|
|
805
|
+
// across descriptor replacement. Settled tails release themselves; a
|
|
806
|
+
// failed operation must not poison later decisions.
|
|
807
|
+
private channelLifecycleTails = new Map<string, Promise<void>>();
|
|
808
|
+
// Bumped when a server's registrations are removed: an operation queued
|
|
809
|
+
// before that is cancelled when it would run, rather than acting on a
|
|
810
|
+
// closing connection or on a later re-registration's entries.
|
|
811
|
+
private serverRemovals = new Map<string, number>();
|
|
812
|
+
// Operations queued or running per channel that will still read its
|
|
813
|
+
// target before confirming anything. Unlike the tail, which settles a few
|
|
814
|
+
// microtasks after its operation's last check, this drops at that check.
|
|
815
|
+
private lifecycleOutstanding = new Map<string, number>();
|
|
816
|
+
// Losing an open receipt does not confirm closure. Keep that distinction
|
|
817
|
+
// private while preserving the public open:boolean availability projection.
|
|
818
|
+
private unconfirmedChannelTargets = new WeakSet<ChannelEntry>();
|
|
819
|
+
|
|
820
|
+
private invalidateTransportConfirmation(entry: ChannelEntry): void {
|
|
821
|
+
entry.open = false;
|
|
822
|
+
this.unconfirmedChannelTargets.add(entry);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Whether the host directs this server's channel lifecycle (§14.1: its
|
|
826
|
+
* `channels.lifecycle` grant). Where it doesn't, the server opens and
|
|
827
|
+
* closes its own channels, and sends no receipt the host could wait for. */
|
|
828
|
+
private directsLifecycle(serverId: string): boolean {
|
|
829
|
+
return CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.lifecycle');
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** A registration's entry: nothing about its transport is known until a
|
|
833
|
+
* receipt for its target confirms it open or closed. */
|
|
834
|
+
private registerEntry(key: string, serverId: string, descriptor: ChannelDescriptor): void {
|
|
835
|
+
const entry: ChannelEntry = { serverId, descriptor, open: false };
|
|
836
|
+
this.channels.set(key, entry);
|
|
837
|
+
this.unconfirmedChannelTargets.add(entry);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
private hasPendingLifecycle(entry: ChannelEntry): boolean {
|
|
841
|
+
return this.channelLifecycleTails.has(this.lifecycleKey(entry.serverId, entry.descriptor.id));
|
|
842
|
+
}
|
|
730
843
|
private handleCoalescedIncoming?: ChannelRegistryOptions['handleCoalescedIncoming'];
|
|
731
844
|
private acceptInbound?: ChannelRegistryOptions['acceptInbound'];
|
|
732
845
|
private channelTools: ToolDefinition[];
|
|
@@ -774,6 +887,7 @@ export class ChannelRegistry {
|
|
|
774
887
|
this.onChannelAutoOpened = options?.onChannelAutoOpened;
|
|
775
888
|
this.homeChannelResolver = options?.homeChannelResolver;
|
|
776
889
|
this.activeChannelResolver = options?.activeChannelResolver;
|
|
890
|
+
this.takesGlobalFallback = options?.takesGlobalFallback;
|
|
777
891
|
this.store = options?.store;
|
|
778
892
|
this.initializeLifecycleStore();
|
|
779
893
|
this.initializeLabelHistoryStore();
|
|
@@ -817,11 +931,7 @@ export class ChannelRegistry {
|
|
|
817
931
|
continue;
|
|
818
932
|
}
|
|
819
933
|
const key = `${serverId}:${channel.id}`;
|
|
820
|
-
this.
|
|
821
|
-
serverId,
|
|
822
|
-
descriptor: channel,
|
|
823
|
-
open: false,
|
|
824
|
-
});
|
|
934
|
+
this.registerEntry(key, serverId, channel);
|
|
825
935
|
this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
826
936
|
registeredIds.push(channel.id);
|
|
827
937
|
results.push({ id: channel.id, accepted: true });
|
|
@@ -862,6 +972,7 @@ export class ChannelRegistry {
|
|
|
862
972
|
// channels/register — a changed-set cannot smuggle in what register
|
|
863
973
|
// would have rejected (PR #79 review blocker 5).
|
|
864
974
|
const addedResults: Array<{ id: string; accepted: boolean; reason?: string }> = [];
|
|
975
|
+
const retargeted: ChannelDescriptor[] = [];
|
|
865
976
|
// Process removed channels — itemized truthfully: removal of a channel
|
|
866
977
|
// we never had is reported, not silently accepted.
|
|
867
978
|
if (params.removed) {
|
|
@@ -891,6 +1002,21 @@ export class ChannelRegistry {
|
|
|
891
1002
|
this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, channelId: channel.id, reason: 'update for unregistered channel' });
|
|
892
1003
|
continue;
|
|
893
1004
|
}
|
|
1005
|
+
// A live subscription to the old target does not confirm the new
|
|
1006
|
+
// type/address. Label-only updates preserve that confirmation. A
|
|
1007
|
+
// channel decided open (or tuned out) is opened at its new target.
|
|
1008
|
+
if (!sameChannelTarget(existing.descriptor, channel)) {
|
|
1009
|
+
this.invalidateTransportConfirmation(existing);
|
|
1010
|
+
// An operation queued or running for this channel reads its target
|
|
1011
|
+
// again before it confirms anything, and after a failed RPC, so it
|
|
1012
|
+
// opens the new one. With none, a channel decided open (or tuned
|
|
1013
|
+
// out) is reconciled here.
|
|
1014
|
+
const desired = this.getDesiredState(serverId, channel.id);
|
|
1015
|
+
if ((desired === 'open' || desired === 'tuned-out') &&
|
|
1016
|
+
!this.lifecycleOutstanding.has(this.lifecycleKey(serverId, channel.id))) {
|
|
1017
|
+
retargeted.push(channel);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
894
1020
|
existing.descriptor = channel;
|
|
895
1021
|
this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
896
1022
|
addedResults.push({ id: channel.id, accepted: true });
|
|
@@ -908,11 +1034,7 @@ export class ChannelRegistry {
|
|
|
908
1034
|
continue;
|
|
909
1035
|
}
|
|
910
1036
|
const key = `${serverId}:${channel.id}`;
|
|
911
|
-
this.
|
|
912
|
-
serverId,
|
|
913
|
-
descriptor: channel,
|
|
914
|
-
open: false,
|
|
915
|
-
});
|
|
1037
|
+
this.registerEntry(key, serverId, channel);
|
|
916
1038
|
this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
|
|
917
1039
|
addedResults.push({ id: channel.id, accepted: true });
|
|
918
1040
|
accepted.push(channel);
|
|
@@ -927,7 +1049,8 @@ export class ChannelRegistry {
|
|
|
927
1049
|
// settled above, so nothing in the response depends on reconciling.
|
|
928
1050
|
responder?.respond({ results: addedResults });
|
|
929
1051
|
|
|
930
|
-
|
|
1052
|
+
const reconcile = [...accepted, ...retargeted];
|
|
1053
|
+
if (reconcile.length > 0) await this.reconcileChannels(serverId, reconcile);
|
|
931
1054
|
|
|
932
1055
|
this.emitTraceFn({
|
|
933
1056
|
type: 'mcpl:channels-changed',
|
|
@@ -1111,15 +1234,31 @@ export class ChannelRegistry {
|
|
|
1111
1234
|
// exactly the authority a self-attested channel would be stealing).
|
|
1112
1235
|
// A coalesced item is "accepted" only once the coalescer admits it, so
|
|
1113
1236
|
// for those this runs after the hook (below).
|
|
1237
|
+
const incomingEntry = this.channels.get(incomingKey)!;
|
|
1114
1238
|
const markAccepted = () => {
|
|
1115
1239
|
this.defaultPublishChannel = message.channelId;
|
|
1116
1240
|
this.defaultPublishMessageId = message.messageId;
|
|
1117
1241
|
this.defaultPublishThreadId = message.threadId;
|
|
1118
|
-
//
|
|
1119
|
-
// the
|
|
1120
|
-
//
|
|
1121
|
-
//
|
|
1122
|
-
|
|
1242
|
+
// Where the host directs this server's lifecycle, only a receipt for
|
|
1243
|
+
// the current target confirms it: traffic shows the transport open
|
|
1244
|
+
// only for a confirmed target, and only for a channel still decided
|
|
1245
|
+
// open or tuned out. After a retarget, traffic for this id may still
|
|
1246
|
+
// come from the old subscription, and a close receipt that landed
|
|
1247
|
+
// first (or during the coalescer's await) stands. Where the host
|
|
1248
|
+
// doesn't direct it, the server opens its own channels and its
|
|
1249
|
+
// traffic is the only evidence the host gets, so it marks the
|
|
1250
|
+
// channel open, as it always has. The entry is the one the message
|
|
1251
|
+
// was accepted for: one a re-registration put in its place meanwhile
|
|
1252
|
+
// waits for its own evidence. Durable desired state still changes
|
|
1253
|
+
// exclusively through lifecycle operations.
|
|
1254
|
+
if (this.directsLifecycle(serverId)) {
|
|
1255
|
+
if (this.unconfirmedChannelTargets.has(incomingEntry)) return;
|
|
1256
|
+
const desired = this.getDesiredState(serverId, message.channelId);
|
|
1257
|
+
if (desired !== 'open' && desired !== 'tuned-out') return;
|
|
1258
|
+
} else {
|
|
1259
|
+
this.unconfirmedChannelTargets.delete(incomingEntry);
|
|
1260
|
+
}
|
|
1261
|
+
incomingEntry.open = true;
|
|
1123
1262
|
};
|
|
1124
1263
|
if (!coalesced) markAccepted();
|
|
1125
1264
|
|
|
@@ -1508,7 +1647,7 @@ export class ChannelRegistry {
|
|
|
1508
1647
|
});
|
|
1509
1648
|
|
|
1510
1649
|
case 'channel_publish':
|
|
1511
|
-
return this.handleToolPublish(input as { channelId: string; content: string });
|
|
1650
|
+
return this.handleToolPublish(input as { channelId: string; content: string }, origin);
|
|
1512
1651
|
|
|
1513
1652
|
case 'think':
|
|
1514
1653
|
return this.handleToolThink(input as { content?: string });
|
|
@@ -1543,9 +1682,15 @@ export class ChannelRegistry {
|
|
|
1543
1682
|
// (heartbeats). Without this, a fork
|
|
1544
1683
|
// was advertised the global locus but published somewhere else; and a trunk
|
|
1545
1684
|
// agent was told the wrong channel under concurrency.
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
const
|
|
1685
|
+
// Each lookup is keyed on whether a name was given, not its truthiness:
|
|
1686
|
+
// '' is a name an ephemeral run can have, and resolveLocus reads it too.
|
|
1687
|
+
const home = agentName !== undefined ? this.homeChannelResolver?.(agentName) : undefined;
|
|
1688
|
+
const active = agentName !== undefined ? this.activeChannelResolver?.(agentName) : undefined;
|
|
1689
|
+
// An ephemeral run takes no global fallback (takesGlobalFallback): neither
|
|
1690
|
+
// the latest inbound message's channel as its outgoing one, nor that
|
|
1691
|
+
// message as the one it is replying to.
|
|
1692
|
+
const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel;
|
|
1693
|
+
const outgoing = home ?? active ?? fallback;
|
|
1549
1694
|
|
|
1550
1695
|
if (openChannels.length === 0 && !outgoing) {
|
|
1551
1696
|
return undefined;
|
|
@@ -1556,9 +1701,9 @@ export class ChannelRegistry {
|
|
|
1556
1701
|
// Incoming: the most-recent inbound message (what the agent is replying to).
|
|
1557
1702
|
// Left process-global — per-channel inbound tracking (the right messageId for
|
|
1558
1703
|
// a fork's own channel) is a separate concern from the outbound routing fix.
|
|
1559
|
-
if (
|
|
1704
|
+
if (fallback && this.defaultPublishMessageId) {
|
|
1560
1705
|
context.incoming = {
|
|
1561
|
-
channelId:
|
|
1706
|
+
channelId: fallback,
|
|
1562
1707
|
messageId: this.defaultPublishMessageId,
|
|
1563
1708
|
threadId: this.defaultPublishThreadId,
|
|
1564
1709
|
};
|
|
@@ -1589,6 +1734,10 @@ export class ChannelRegistry {
|
|
|
1589
1734
|
* server's channels don't linger and route speech into the void.
|
|
1590
1735
|
*/
|
|
1591
1736
|
removeServer(serverId: string): void {
|
|
1737
|
+
// Every lifecycle operation queued for this server is cancelled when it
|
|
1738
|
+
// would run (ChannelLifecycleCancelledError), so none acts on a closing
|
|
1739
|
+
// connection or on a later re-registration's entries.
|
|
1740
|
+
this.serverRemovals.set(serverId, (this.serverRemovals.get(serverId) ?? 0) + 1);
|
|
1592
1741
|
for (const [key, entry] of this.channels) {
|
|
1593
1742
|
if (entry.serverId !== serverId) continue;
|
|
1594
1743
|
this.channels.delete(key);
|
|
@@ -2260,6 +2409,225 @@ export class ChannelRegistry {
|
|
|
2260
2409
|
return channel.initiallyOpen !== true && byPolicy;
|
|
2261
2410
|
}
|
|
2262
2411
|
|
|
2412
|
+
/**
|
|
2413
|
+
* Apply current durable intent, serializing the transport rather than the
|
|
2414
|
+
* server's registration ACK. Re-read after every await: registration,
|
|
2415
|
+
* descriptor updates, or a newer decision may have replaced our snapshot.
|
|
2416
|
+
*/
|
|
2417
|
+
private applyDesiredChannelState(
|
|
2418
|
+
serverId: string,
|
|
2419
|
+
channelId: string,
|
|
2420
|
+
options?: { history?: ChannelHistoryRequest },
|
|
2421
|
+
): Promise<{ open: boolean; result?: ChannelsOpenResult }> {
|
|
2422
|
+
const lifecycleKey = this.lifecycleKey(serverId, channelId);
|
|
2423
|
+
const removals = this.serverRemovals.get(serverId) ?? 0;
|
|
2424
|
+
const previous = this.channelLifecycleTails.get(lifecycleKey) ?? Promise.resolve();
|
|
2425
|
+
this.lifecycleOutstanding.set(lifecycleKey, (this.lifecycleOutstanding.get(lifecycleKey) ?? 0) + 1);
|
|
2426
|
+
const operation = previous.catch(() => {}).then(
|
|
2427
|
+
() => this.runLifecycleOperation(serverId, channelId, removals, options),
|
|
2428
|
+
);
|
|
2429
|
+
// Keep the rejection visible to existing joiners, while observing it
|
|
2430
|
+
// immediately and letting each queued successor start after either result.
|
|
2431
|
+
const tail = operation.then(() => {});
|
|
2432
|
+
this.channelLifecycleTails.set(lifecycleKey, tail);
|
|
2433
|
+
const cleanup = () => {
|
|
2434
|
+
if (this.channelLifecycleTails.get(lifecycleKey) === tail) {
|
|
2435
|
+
this.channelLifecycleTails.delete(lifecycleKey);
|
|
2436
|
+
}
|
|
2437
|
+
};
|
|
2438
|
+
void tail.then(cleanup, cleanup);
|
|
2439
|
+
return operation;
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
/** One queued lifecycle operation, run when its turn in the queue comes. */
|
|
2443
|
+
private async runLifecycleOperation(
|
|
2444
|
+
serverId: string,
|
|
2445
|
+
channelId: string,
|
|
2446
|
+
removals: number,
|
|
2447
|
+
options?: { history?: ChannelHistoryRequest },
|
|
2448
|
+
): Promise<{ open: boolean; result?: ChannelsOpenResult }> {
|
|
2449
|
+
const channelKey = `${serverId}:${channelId}`;
|
|
2450
|
+
const lifecycleKey = this.lifecycleKey(serverId, channelId);
|
|
2451
|
+
try {
|
|
2452
|
+
let attempts = 0;
|
|
2453
|
+
for (;;) {
|
|
2454
|
+
if ((this.serverRemovals.get(serverId) ?? 0) !== removals) {
|
|
2455
|
+
throw new ChannelLifecycleCancelledError(
|
|
2456
|
+
`Channel ${channelId}: its server's registrations were removed while this operation was queued`,
|
|
2457
|
+
);
|
|
2458
|
+
}
|
|
2459
|
+
const entry = this.channels.get(channelKey);
|
|
2460
|
+
if (!entry) throw new Error(`Channel is no longer registered: ${channelId}`);
|
|
2461
|
+
const server = this.serverRegistry.getServer(serverId);
|
|
2462
|
+
if (!server) throw new Error(`Server not found: ${serverId}`);
|
|
2463
|
+
if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
|
|
2464
|
+
throw new Error(`channels.lifecycle not in "${serverId}"'s effective grant (§14.1)`);
|
|
2465
|
+
}
|
|
2466
|
+
const descriptor = entry.descriptor;
|
|
2467
|
+
const desired = this.getDesiredState(serverId, channelId);
|
|
2468
|
+
const open = desired === 'open' || desired === 'tuned-out';
|
|
2469
|
+
// Per-RPC timeouts do not bound a stream of promptly successful but
|
|
2470
|
+
// continually superseded receipts. Bound this whole operation, then
|
|
2471
|
+
// release its queue slot and surface the failure to delivery joiners.
|
|
2472
|
+
// Its open flag needs no change here: every retry follows a replaced
|
|
2473
|
+
// registration, a retarget (already invalidated, whether the old
|
|
2474
|
+
// target's RPC was answered or failed), or a receipt for this very
|
|
2475
|
+
// target.
|
|
2476
|
+
if (attempts >= MAX_CHANNEL_LIFECYCLE_ATTEMPTS) {
|
|
2477
|
+
const error = new ChannelLifecycleConvergenceError(
|
|
2478
|
+
`Channel ${channelId} did not converge after ${attempts} lifecycle attempts; its target or desired state kept changing`,
|
|
2479
|
+
);
|
|
2480
|
+
this.emitTraceFn({
|
|
2481
|
+
type: 'mcpl:channel-reconcile-failed',
|
|
2482
|
+
serverId, channelId, desired, attempts, error: error.message,
|
|
2483
|
+
});
|
|
2484
|
+
throw error;
|
|
2485
|
+
}
|
|
2486
|
+
attempts++;
|
|
2487
|
+
const sameTarget = () => this.serverRegistry.getServer(serverId) === server &&
|
|
2488
|
+
sameChannelTarget(entry.descriptor, descriptor);
|
|
2489
|
+
let result: ChannelsOpenResult | undefined;
|
|
2490
|
+
try {
|
|
2491
|
+
if (open) {
|
|
2492
|
+
result = await server.sendChannelsOpen({
|
|
2493
|
+
channelId, type: descriptor.type, address: descriptor.address,
|
|
2494
|
+
...(options?.history ? { history: options.history } : {}),
|
|
2495
|
+
});
|
|
2496
|
+
} else {
|
|
2497
|
+
await server.sendChannelsClose({ channelId });
|
|
2498
|
+
}
|
|
2499
|
+
} catch (error) {
|
|
2500
|
+
// An RPC can fail after the server acted, so a failure for the
|
|
2501
|
+
// current target leaves its outcome unknown: it is marked
|
|
2502
|
+
// unconfirmed with open unchanged, which keeps a confirmed-open
|
|
2503
|
+
// channel usable after a failed backscroll (the mark is read only
|
|
2504
|
+
// while not open). A failure from an older connection or target
|
|
2505
|
+
// also withdraws the open flag.
|
|
2506
|
+
if (this.channels.get(channelKey) === entry) {
|
|
2507
|
+
if (sameTarget()) this.unconfirmedChannelTargets.add(entry);
|
|
2508
|
+
else this.invalidateTransportConfirmation(entry);
|
|
2509
|
+
}
|
|
2510
|
+
// A retarget while this RPC was out queued nothing for the new
|
|
2511
|
+
// target, since handleChanged leaves that to this operation. So a
|
|
2512
|
+
// failure for the old target says nothing about the new one, and a
|
|
2513
|
+
// channel still decided open or tuned out tries its new target,
|
|
2514
|
+
// within the attempt limit.
|
|
2515
|
+
if (this.channels.get(channelKey) === entry && !sameChannelTarget(entry.descriptor, descriptor)) {
|
|
2516
|
+
const now = this.getDesiredState(serverId, channelId);
|
|
2517
|
+
if (now === 'open' || now === 'tuned-out') continue;
|
|
2518
|
+
}
|
|
2519
|
+
throw error;
|
|
2520
|
+
}
|
|
2521
|
+
// A server can answer for a channel other than the one named: one
|
|
2522
|
+
// that falls back to its first channel of the type for an id it no
|
|
2523
|
+
// longer knows opens that channel and returns its history. Whatever
|
|
2524
|
+
// becomes of this receipt, the server did that: say so, and undo the
|
|
2525
|
+
// open it made if the host wants that channel closed.
|
|
2526
|
+
const substitute = open ? substitutedChannel(result, channelId) : undefined;
|
|
2527
|
+
if (substitute !== undefined) {
|
|
2528
|
+
// Each id prints through logValue, as the rejection lines' ids do,
|
|
2529
|
+
// so an odd one can't add a line or a field.
|
|
2530
|
+
const answered = logValue(substitute.kind === 'other' ? substitute.id : substitute.value);
|
|
2531
|
+
console.error(
|
|
2532
|
+
`[channel-open-substituted] server=${serverId} requested=${logValue(channelId)} answered=${answered}`,
|
|
2533
|
+
);
|
|
2534
|
+
this.emitTraceFn({ type: 'mcpl:channel-open-substituted', serverId, channelId, answeredFor: answered });
|
|
2535
|
+
// A malformed id names nothing registered here, so there is
|
|
2536
|
+
// nothing to close.
|
|
2537
|
+
if (substitute.kind === 'other') this.closeSubstitutedChannel(serverId, substitute.id);
|
|
2538
|
+
}
|
|
2539
|
+
// Never apply an old receipt to the current target, even transiently:
|
|
2540
|
+
// a corrective open might fail before that stale bit is replaced.
|
|
2541
|
+
if (this.channels.get(channelKey) !== entry) continue;
|
|
2542
|
+
if (!sameTarget()) {
|
|
2543
|
+
this.invalidateTransportConfirmation(entry);
|
|
2544
|
+
continue;
|
|
2545
|
+
}
|
|
2546
|
+
// A current receipt for another channel confirms nothing about this
|
|
2547
|
+
// one, and its history is another channel's. So the open fails,
|
|
2548
|
+
// nothing in the answer is used, and this channel's open state is
|
|
2549
|
+
// unconfirmed: a server that doesn't resolve this id may not resolve
|
|
2550
|
+
// its sends either.
|
|
2551
|
+
if (substitute !== undefined) {
|
|
2552
|
+
this.invalidateTransportConfirmation(entry);
|
|
2553
|
+
throw new ChannelOpenSubstitutionError(
|
|
2554
|
+
`Server "${serverId}" answered channels/open for ${channelId} with ` +
|
|
2555
|
+
(substitute.kind === 'other' ? 'a different channel' : 'a channel id that is not a string') +
|
|
2556
|
+
`; ${channelId} is not marked open, and nothing in the answer is used`,
|
|
2557
|
+
);
|
|
2558
|
+
}
|
|
2559
|
+
entry.open = open;
|
|
2560
|
+
this.unconfirmedChannelTargets.delete(entry);
|
|
2561
|
+
const now = this.getDesiredState(serverId, channelId);
|
|
2562
|
+
if (open !== (now === 'open' || now === 'tuned-out')) continue;
|
|
2563
|
+
return { open, result };
|
|
2564
|
+
}
|
|
2565
|
+
} finally {
|
|
2566
|
+
// Synchronous with the operation's last look at its target: from here
|
|
2567
|
+
// on, nothing it does will open a new target.
|
|
2568
|
+
const left = (this.lifecycleOutstanding.get(lifecycleKey) ?? 1) - 1;
|
|
2569
|
+
if (left > 0) this.lifecycleOutstanding.set(lifecycleKey, left);
|
|
2570
|
+
else this.lifecycleOutstanding.delete(lifecycleKey);
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
/**
|
|
2575
|
+
* A server that answered an open with another channel really opened that
|
|
2576
|
+
* one. When it's registered here and the host wants it closed, close it
|
|
2577
|
+
* through its own lifecycle queue: otherwise its traffic would reach the
|
|
2578
|
+
* resident, marked open by its first incoming message. A channel the host
|
|
2579
|
+
* wants open is left alone, and a close can't be answered for another
|
|
2580
|
+
* channel, so this never starts another substitution. An id the host
|
|
2581
|
+
* doesn't know needs nothing: §14.5 refuses its traffic.
|
|
2582
|
+
*/
|
|
2583
|
+
private closeSubstitutedChannel(serverId: string, channelId: string): void {
|
|
2584
|
+
if (!this.channels.has(`${serverId}:${channelId}`)) return;
|
|
2585
|
+
if (this.getDesiredState(serverId, channelId) !== 'closed') return;
|
|
2586
|
+
this.applyDesiredChannelState(serverId, channelId).catch((err) => {
|
|
2587
|
+
// Removal while queued is cancellation, as in reconcileChannels, and
|
|
2588
|
+
// exhausted convergence emitted its own diagnostic.
|
|
2589
|
+
if (!this.channels.has(`${serverId}:${channelId}`) ||
|
|
2590
|
+
err instanceof ChannelLifecycleCancelledError ||
|
|
2591
|
+
err instanceof ChannelLifecycleConvergenceError) return;
|
|
2592
|
+
this.emitTraceFn({
|
|
2593
|
+
type: 'mcpl:channel-reconcile-failed',
|
|
2594
|
+
serverId, channelId, desired: 'closed', error: (err as Error).message,
|
|
2595
|
+
});
|
|
2596
|
+
});
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
/**
|
|
2600
|
+
* Join outstanding lifecycle work without recording new intent. Retargeting
|
|
2601
|
+
* may have invalidated open confirmation while the decision remains pending.
|
|
2602
|
+
* A close that settles during the wait remains authoritative; waiting for
|
|
2603
|
+
* it is not an instruction to open the channel again.
|
|
2604
|
+
*/
|
|
2605
|
+
private async waitForOpenChannel(entry: ChannelEntry): Promise<ChannelEntry> {
|
|
2606
|
+
const lifecycleKey = this.lifecycleKey(entry.serverId, entry.descriptor.id);
|
|
2607
|
+
for (;;) {
|
|
2608
|
+
const pending = this.channelLifecycleTails.get(lifecycleKey);
|
|
2609
|
+
if (!pending) break;
|
|
2610
|
+
try {
|
|
2611
|
+
await pending;
|
|
2612
|
+
} catch (error) {
|
|
2613
|
+
// A redundant backscroll failure can leave a known-open channel
|
|
2614
|
+
// usable. Exhausted convergence is different: it never established
|
|
2615
|
+
// the current target, so joined delivery must fail visibly.
|
|
2616
|
+
if (error instanceof ChannelLifecycleConvergenceError) throw error;
|
|
2617
|
+
}
|
|
2618
|
+
// A settled tail releases its own key. One still current after it
|
|
2619
|
+
// settled would be awaited again without ever yielding to the event
|
|
2620
|
+
// loop, so stop waiting rather than spin.
|
|
2621
|
+
if (this.channelLifecycleTails.get(lifecycleKey) === pending) break;
|
|
2622
|
+
}
|
|
2623
|
+
const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
|
|
2624
|
+
if (!current) throw new Error('Channel registration was removed while delivery waited');
|
|
2625
|
+
if (!current.open || this.getDesiredState(current.serverId, current.descriptor.id) === 'closed') {
|
|
2626
|
+
throw new Error('Channel is not confirmed open for delivery, or was selected for closure while delivery waited');
|
|
2627
|
+
}
|
|
2628
|
+
return current;
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2263
2631
|
private async reconcileChannels(
|
|
2264
2632
|
serverId: string,
|
|
2265
2633
|
channels: ChannelDescriptor[],
|
|
@@ -2285,54 +2653,18 @@ export class ChannelRegistry {
|
|
|
2285
2653
|
// first boot on a new policy doesn't produce one notice per channel.
|
|
2286
2654
|
const policyOpened: Array<{ channelId: string; label?: string }> = [];
|
|
2287
2655
|
|
|
2656
|
+
// Record all admissions before waiting on any channel. A slow first RPC
|
|
2657
|
+
// must not delay intent for the rest of this registration batch.
|
|
2288
2658
|
for (const channel of channels) {
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
const key = `${serverId}:${channel.id}`;
|
|
2293
|
-
const desired = this.getDesiredState(serverId, channel.id);
|
|
2294
|
-
const entry = this.channels.get(key);
|
|
2295
|
-
// Tuned-out sits on the OPEN side of reconcile: traffic must keep
|
|
2296
|
-
// arriving (the subconscious reads it); only main's wake/visibility
|
|
2297
|
-
// is diverted, downstream at ingestion.
|
|
2298
|
-
if (desired !== 'open' && desired !== 'tuned-out') {
|
|
2299
|
-
try {
|
|
2300
|
-
await server.sendChannelsClose({ channelId: channel.id });
|
|
2301
|
-
if (entry) entry.open = false;
|
|
2302
|
-
} catch (err) {
|
|
2303
|
-
this.emitTraceFn({
|
|
2304
|
-
type: 'mcpl:channel-reconcile-failed',
|
|
2305
|
-
serverId,
|
|
2306
|
-
channelId: channel.id,
|
|
2307
|
-
desired,
|
|
2308
|
-
error: (err as Error).message,
|
|
2309
|
-
});
|
|
2310
|
-
}
|
|
2311
|
-
continue;
|
|
2312
|
-
}
|
|
2313
|
-
|
|
2314
|
-
try {
|
|
2315
|
-
await server.sendChannelsOpen({
|
|
2316
|
-
channelId: channel.id,
|
|
2317
|
-
type: channel.type,
|
|
2318
|
-
address: channel.address,
|
|
2319
|
-
});
|
|
2320
|
-
if (entry) entry.open = true;
|
|
2321
|
-
} catch (err) {
|
|
2322
|
-
if (entry) entry.open = false;
|
|
2323
|
-
this.emitTraceFn({
|
|
2324
|
-
type: 'mcpl:channel-reconcile-failed',
|
|
2325
|
-
serverId,
|
|
2326
|
-
channelId: channel.id,
|
|
2327
|
-
desired,
|
|
2328
|
-
error: (err as Error).message,
|
|
2329
|
-
});
|
|
2659
|
+
const entry = this.channels.get(`${serverId}:${channel.id}`);
|
|
2660
|
+
if (entry && this.ensureInitialDesiredState(serverId, entry.descriptor)) {
|
|
2661
|
+
policyOpened.push({ channelId: channel.id, label: entry.descriptor.label });
|
|
2330
2662
|
}
|
|
2331
2663
|
}
|
|
2332
2664
|
|
|
2333
|
-
// Announce
|
|
2334
|
-
//
|
|
2335
|
-
//
|
|
2665
|
+
// Announce the durable admission decision before awaiting transport.
|
|
2666
|
+
// This is intent, not confirmation that an open RPC succeeded. It stays
|
|
2667
|
+
// truthful if the first attempt fails, and a retry needs no new admission.
|
|
2336
2668
|
if (policyOpened.length > 0) {
|
|
2337
2669
|
try {
|
|
2338
2670
|
this.onChannelAutoOpened?.({
|
|
@@ -2344,6 +2676,30 @@ export class ChannelRegistry {
|
|
|
2344
2676
|
console.error('onChannelAutoOpened (policy) failed:', err);
|
|
2345
2677
|
}
|
|
2346
2678
|
}
|
|
2679
|
+
|
|
2680
|
+
// Each channel has its own lifecycle queue, so a held RPC for one (up to
|
|
2681
|
+
// its timeout, times its attempts) doesn't delay its siblings.
|
|
2682
|
+
await Promise.allSettled(channels.map(async (channel) => {
|
|
2683
|
+
if (!this.channels.has(`${serverId}:${channel.id}`)) return;
|
|
2684
|
+
try {
|
|
2685
|
+
await this.applyDesiredChannelState(serverId, channel.id);
|
|
2686
|
+
} catch (err) {
|
|
2687
|
+
// Removal while queued is cancellation, not a failed subscription.
|
|
2688
|
+
// Exhausted convergence and a substituted answer already emitted
|
|
2689
|
+
// their own diagnostics.
|
|
2690
|
+
if (!this.channels.has(`${serverId}:${channel.id}`) ||
|
|
2691
|
+
err instanceof ChannelLifecycleCancelledError ||
|
|
2692
|
+
err instanceof ChannelLifecycleConvergenceError ||
|
|
2693
|
+
err instanceof ChannelOpenSubstitutionError) return;
|
|
2694
|
+
this.emitTraceFn({
|
|
2695
|
+
type: 'mcpl:channel-reconcile-failed',
|
|
2696
|
+
serverId,
|
|
2697
|
+
channelId: channel.id,
|
|
2698
|
+
desired: this.getDesiredState(serverId, channel.id),
|
|
2699
|
+
error: (err as Error).message,
|
|
2700
|
+
});
|
|
2701
|
+
}
|
|
2702
|
+
}));
|
|
2347
2703
|
}
|
|
2348
2704
|
|
|
2349
2705
|
// ==========================================================================
|
|
@@ -2465,37 +2821,39 @@ export class ChannelRegistry {
|
|
|
2465
2821
|
}
|
|
2466
2822
|
|
|
2467
2823
|
/**
|
|
2468
|
-
*
|
|
2469
|
-
*
|
|
2470
|
-
*
|
|
2471
|
-
*
|
|
2472
|
-
*
|
|
2824
|
+
* Record durable desired-open intent, then queue the channel's lifecycle
|
|
2825
|
+
* work (applyDesiredChannelState), which tells the server to subscribe and
|
|
2826
|
+
* marks the live entry open on a receipt for its current target. Every
|
|
2827
|
+
* open the agent or its speech asks for — the channel_open tool, delivery
|
|
2828
|
+
* into a closed locus, an explicit send into a closed channel — comes
|
|
2829
|
+
* here. Reconciliation queues that work directly, as the closes do
|
|
2830
|
+
* (channel_close, a decline, and the close of a substituted channel).
|
|
2473
2831
|
* Desired state is recorded BEFORE the server round-trip: intent sticks
|
|
2474
2832
|
* even if the subscribe fails, and reconciliation retries later.
|
|
2833
|
+
* Automatic delivery needs open transport, not a change of attention:
|
|
2834
|
+
* preserve tune-out epochs both before and during the round-trip.
|
|
2835
|
+
*
|
|
2836
|
+
* `opened` says whether this call turned a closed channel open, which is
|
|
2837
|
+
* what the delivery and reply notices tell the resident. Reconfirming the
|
|
2838
|
+
* transport of a channel already decided open or tuned out isn't one.
|
|
2475
2839
|
*/
|
|
2476
2840
|
private async openChannelNow(
|
|
2477
2841
|
entry: ChannelEntry,
|
|
2478
|
-
source:
|
|
2842
|
+
source: 'agent-tool' | 'opened-by-reply' | 'opened-by-delivery',
|
|
2479
2843
|
history?: ChannelHistoryRequest,
|
|
2480
|
-
): Promise<ChannelsOpenResult> {
|
|
2481
|
-
|
|
2482
|
-
const
|
|
2483
|
-
|
|
2484
|
-
|
|
2844
|
+
): Promise<{ result: ChannelsOpenResult; opened: boolean }> {
|
|
2845
|
+
const transportOnly = source !== 'agent-tool';
|
|
2846
|
+
const before = this.getDesiredState(entry.serverId, entry.descriptor.id);
|
|
2847
|
+
const opened = before !== 'open' && before !== 'tuned-out';
|
|
2848
|
+
if (!transportOnly || before !== 'tuned-out') {
|
|
2849
|
+
this.setDesiredState(entry.serverId, entry.descriptor.id, 'open', source);
|
|
2485
2850
|
}
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
throw new Error(
|
|
2851
|
+
const applied = await this.applyDesiredChannelState(entry.serverId, entry.descriptor.id, { history });
|
|
2852
|
+
const desired = this.getDesiredState(entry.serverId, entry.descriptor.id);
|
|
2853
|
+
if (!applied.open || (desired !== 'open' && !(transportOnly && desired === 'tuned-out'))) {
|
|
2854
|
+
throw new Error('Channel open was superseded by a newer lifecycle decision');
|
|
2490
2855
|
}
|
|
2491
|
-
|
|
2492
|
-
channelId: entry.descriptor.id,
|
|
2493
|
-
type: entry.descriptor.type,
|
|
2494
|
-
address: entry.descriptor.address,
|
|
2495
|
-
...(history ? { history } : {}),
|
|
2496
|
-
});
|
|
2497
|
-
entry.open = true;
|
|
2498
|
-
return result;
|
|
2856
|
+
return { result: applied.result!, opened };
|
|
2499
2857
|
}
|
|
2500
2858
|
|
|
2501
2859
|
/**
|
|
@@ -2706,19 +3064,25 @@ export class ChannelRegistry {
|
|
|
2706
3064
|
}
|
|
2707
3065
|
if (matches.length === 0) return { status: 'unknown-channel' };
|
|
2708
3066
|
if (matches.length > 1) return { status: 'ambiguous' };
|
|
2709
|
-
|
|
2710
|
-
const resolved = { channelId: entry.descriptor.id, label: entry.descriptor.label };
|
|
2711
|
-
if (entry.open && this.getDesiredState(entry.serverId, entry.descriptor.id) === 'open') {
|
|
2712
|
-
return { status: 'already-open', ...resolved };
|
|
2713
|
-
}
|
|
3067
|
+
let entry = matches[0]!;
|
|
2714
3068
|
try {
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
3069
|
+
if (this.hasPendingLifecycle(entry)) {
|
|
3070
|
+
entry = await this.waitForOpenChannel(entry);
|
|
3071
|
+
}
|
|
3072
|
+
const desired = this.getDesiredState(entry.serverId, entry.descriptor.id);
|
|
3073
|
+
if (entry.open && (desired === 'open' || desired === 'tuned-out')) {
|
|
3074
|
+
return { status: 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
|
|
3075
|
+
}
|
|
3076
|
+
const { opened } = await this.openChannelNow(entry, 'opened-by-reply');
|
|
3077
|
+
entry = await this.waitForOpenChannel(entry);
|
|
3078
|
+
if (opened) {
|
|
3079
|
+
this.emitTraceFn({
|
|
3080
|
+
type: 'mcpl:channel-opened-by-send',
|
|
3081
|
+
serverId: entry.serverId,
|
|
3082
|
+
channelId: entry.descriptor.id,
|
|
3083
|
+
});
|
|
3084
|
+
}
|
|
3085
|
+
return { status: opened ? 'opened' : 'already-open', channelId: entry.descriptor.id, label: entry.descriptor.label };
|
|
2722
3086
|
} catch (err) {
|
|
2723
3087
|
this.emitTraceFn({
|
|
2724
3088
|
type: 'mcpl:channel-open-failed',
|
|
@@ -2726,7 +3090,9 @@ export class ChannelRegistry {
|
|
|
2726
3090
|
channelId: entry.descriptor.id,
|
|
2727
3091
|
error: (err as Error).message,
|
|
2728
3092
|
});
|
|
2729
|
-
|
|
3093
|
+
const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`);
|
|
3094
|
+
return { status: 'open-failed', channelId: entry.descriptor.id,
|
|
3095
|
+
...(current ? { label: current.descriptor.label } : {}) };
|
|
2730
3096
|
}
|
|
2731
3097
|
}
|
|
2732
3098
|
|
|
@@ -2765,7 +3131,7 @@ export class ChannelRegistry {
|
|
|
2765
3131
|
}
|
|
2766
3132
|
|
|
2767
3133
|
const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open';
|
|
2768
|
-
if (entry.open && alreadyDesiredOpen && !input.backscroll) {
|
|
3134
|
+
if (entry.open && alreadyDesiredOpen && !input.backscroll && !this.hasPendingLifecycle(entry)) {
|
|
2769
3135
|
return {
|
|
2770
3136
|
success: true,
|
|
2771
3137
|
data: { channelId: input.channelId, status: 'already open' },
|
|
@@ -2779,7 +3145,7 @@ export class ChannelRegistry {
|
|
|
2779
3145
|
...(input.beforeMessageId ? { beforeMessageId: input.beforeMessageId } : {}),
|
|
2780
3146
|
}
|
|
2781
3147
|
: undefined;
|
|
2782
|
-
const result = await this.openChannelNow(entry, 'agent-tool', history);
|
|
3148
|
+
const { result } = await this.openChannelNow(entry, 'agent-tool', history);
|
|
2783
3149
|
return {
|
|
2784
3150
|
success: true,
|
|
2785
3151
|
data: {
|
|
@@ -2790,9 +3156,18 @@ export class ChannelRegistry {
|
|
|
2790
3156
|
},
|
|
2791
3157
|
};
|
|
2792
3158
|
} catch (err) {
|
|
3159
|
+
// The decision was recorded before the round-trip and outlives its
|
|
3160
|
+
// failure. Say so: otherwise a later delivery that opens the channel
|
|
3161
|
+
// would be the resident's first sign that it was still wanted open.
|
|
3162
|
+
const current = this.channels.get(`${entry.serverId}:${input.channelId}`);
|
|
3163
|
+
const stillSelected = current !== undefined && !current.open &&
|
|
3164
|
+
this.getDesiredState(entry.serverId, input.channelId) === 'open';
|
|
2793
3165
|
return {
|
|
2794
3166
|
success: false,
|
|
2795
|
-
error: `Failed to open channel: ${(err as Error).message}
|
|
3167
|
+
error: `Failed to open channel: ${(err as Error).message}` +
|
|
3168
|
+
(stillSelected
|
|
3169
|
+
? ' (it stays selected for opening: a later delivery or reconnect tries again, and channel_close withdraws that)'
|
|
3170
|
+
: ''),
|
|
2796
3171
|
isError: true,
|
|
2797
3172
|
};
|
|
2798
3173
|
}
|
|
@@ -2828,7 +3203,8 @@ export class ChannelRegistry {
|
|
|
2828
3203
|
}
|
|
2829
3204
|
|
|
2830
3205
|
const alreadyDesiredClosed = this.getDesiredState(entry.serverId, input.channelId) === 'closed';
|
|
2831
|
-
if (!entry.open && alreadyDesiredClosed)
|
|
3206
|
+
if (!entry.open && alreadyDesiredClosed && !this.hasPendingLifecycle(entry) &&
|
|
3207
|
+
!this.unconfirmedChannelTargets.has(entry)) {
|
|
2832
3208
|
return {
|
|
2833
3209
|
success: true,
|
|
2834
3210
|
data: { channelId: input.channelId, status: 'already closed' },
|
|
@@ -2897,8 +3273,10 @@ export class ChannelRegistry {
|
|
|
2897
3273
|
isError: true,
|
|
2898
3274
|
};
|
|
2899
3275
|
}
|
|
2900
|
-
await
|
|
2901
|
-
|
|
3276
|
+
const applied = await this.applyDesiredChannelState(entry.serverId, input.channelId);
|
|
3277
|
+
if (applied.open || this.getDesiredState(entry.serverId, input.channelId) !== 'closed') {
|
|
3278
|
+
throw new Error('Channel close was superseded by a newer lifecycle decision');
|
|
3279
|
+
}
|
|
2902
3280
|
return {
|
|
2903
3281
|
success: true,
|
|
2904
3282
|
data: { channelId: input.channelId, status: 'closed' },
|
|
@@ -2964,6 +3342,23 @@ export class ChannelRegistry {
|
|
|
2964
3342
|
}
|
|
2965
3343
|
}
|
|
2966
3344
|
|
|
3345
|
+
// Where the host directs the lifecycle, a decline goes through the
|
|
3346
|
+
// lifecycle queue like any closed decision, unless a receipt confirmed
|
|
3347
|
+
// the transport closed with nothing pending: an unconfirmed transport may
|
|
3348
|
+
// be open, and pending work must settle on this decision. A failed close
|
|
3349
|
+
// is reported; the decision stands.
|
|
3350
|
+
let closeError: string | undefined;
|
|
3351
|
+
const current = this.channels.get(`${entry.serverId}:${input.channelId}`);
|
|
3352
|
+
if (current && this.directsLifecycle(entry.serverId) &&
|
|
3353
|
+
(this.unconfirmedChannelTargets.has(current) || this.hasPendingLifecycle(current))) {
|
|
3354
|
+
try {
|
|
3355
|
+
const applied = await this.applyDesiredChannelState(entry.serverId, input.channelId);
|
|
3356
|
+
if (applied.open) closeError = 'the close was superseded by a newer lifecycle decision';
|
|
3357
|
+
} catch (error) {
|
|
3358
|
+
closeError = (error as Error).message;
|
|
3359
|
+
}
|
|
3360
|
+
}
|
|
3361
|
+
|
|
2967
3362
|
this.appendLifecycleEvent({
|
|
2968
3363
|
kind: 'invitation-declined',
|
|
2969
3364
|
serverId: entry.serverId,
|
|
@@ -2980,6 +3375,7 @@ export class ChannelRegistry {
|
|
|
2980
3375
|
acknowledged,
|
|
2981
3376
|
...(representation ? { representation } : {}),
|
|
2982
3377
|
...(acknowledgmentError ? { acknowledgmentError } : {}),
|
|
3378
|
+
...(closeError ? { closeError: `Failed to close the channel's transport: ${closeError}` } : {}),
|
|
2983
3379
|
},
|
|
2984
3380
|
};
|
|
2985
3381
|
}
|
|
@@ -2988,7 +3384,8 @@ export class ChannelRegistry {
|
|
|
2988
3384
|
* Handle the synthesized `think` tool — a private reasoning scratchpad. It
|
|
2989
3385
|
* sends nothing, and (unlike before) does NOT silence the turn: trailing
|
|
2990
3386
|
* plain text is still routed as the reply. The thought stays in the agent's
|
|
2991
|
-
* own context/chronicle. To deliberately not reply,
|
|
3387
|
+
* own context/chronicle. To deliberately not reply, an agent that may call
|
|
3388
|
+
* skip_reply uses it.
|
|
2992
3389
|
*/
|
|
2993
3390
|
private handleToolThink(input: { content?: string }): ToolResult {
|
|
2994
3391
|
return {
|
|
@@ -2997,10 +3394,12 @@ export class ChannelRegistry {
|
|
|
2997
3394
|
// tool_use block; don't duplicate it in the result.
|
|
2998
3395
|
data: {
|
|
2999
3396
|
noted: true,
|
|
3397
|
+
// No tool to end the turn with: this handler can't see whether the
|
|
3398
|
+
// caller may call skip_reply, and think's own description says so
|
|
3399
|
+
// where it may.
|
|
3000
3400
|
note:
|
|
3001
3401
|
'Thought recorded (private — not sent anywhere). Same-round text routing depends on ' +
|
|
3002
|
-
'your current same_round_think_text_policy; use agent_settings get to inspect it,
|
|
3003
|
-
'call skip_reply to end the turn without replying.',
|
|
3402
|
+
'your current same_round_think_text_policy; use agent_settings get to inspect it.',
|
|
3004
3403
|
},
|
|
3005
3404
|
};
|
|
3006
3405
|
}
|
|
@@ -3135,7 +3534,12 @@ export class ChannelRegistry {
|
|
|
3135
3534
|
|
|
3136
3535
|
resolveLocus(conversationId: string): string | null {
|
|
3137
3536
|
const home = this.homeChannelResolver?.(conversationId);
|
|
3138
|
-
return home ?? this.activeChannelResolver?.(conversationId) ?? this.
|
|
3537
|
+
return home ?? this.activeChannelResolver?.(conversationId) ?? this.globalFallbackFor(conversationId);
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
/** The process-global fallback channel for this conversation, or null when it takes none (takesGlobalFallback). */
|
|
3541
|
+
private globalFallbackFor(conversationId: string): string | null {
|
|
3542
|
+
return this.takesGlobalFallback?.(conversationId) === false ? null : this.defaultPublishChannel;
|
|
3139
3543
|
}
|
|
3140
3544
|
|
|
3141
3545
|
/**
|
|
@@ -3232,7 +3636,7 @@ export class ChannelRegistry {
|
|
|
3232
3636
|
const at = (): number => Date.now();
|
|
3233
3637
|
const found = this.findExactEntry(target);
|
|
3234
3638
|
if ('error' in found) return { status: 'failed', reason: found.error, at: at() };
|
|
3235
|
-
|
|
3639
|
+
let entry = found.entry;
|
|
3236
3640
|
const destination: PublishDestination = {
|
|
3237
3641
|
serverId: entry.serverId,
|
|
3238
3642
|
channelId: entry.descriptor.id,
|
|
@@ -3256,29 +3660,60 @@ export class ChannelRegistry {
|
|
|
3256
3660
|
};
|
|
3257
3661
|
}
|
|
3258
3662
|
|
|
3259
|
-
|
|
3663
|
+
// Lifecycle work already queued for the channel settles first: words go
|
|
3664
|
+
// out only into the channel it leaves, and fail visibly if it leaves
|
|
3665
|
+
// none open.
|
|
3666
|
+
if (this.hasPendingLifecycle(entry)) {
|
|
3260
3667
|
try {
|
|
3261
|
-
await this.
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3668
|
+
entry = await this.waitForOpenChannel(entry);
|
|
3669
|
+
} catch (err) {
|
|
3670
|
+
return {
|
|
3671
|
+
status: 'failed',
|
|
3672
|
+
destination,
|
|
3673
|
+
reason: `lifecycle work prevented delivery: ${connectorText(err instanceof Error ? err.message : String(err))}`,
|
|
3674
|
+
at: at(),
|
|
3675
|
+
};
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3679
|
+
// INVARIANT: it is not possible to send into a closed channel. A send
|
|
3680
|
+
// into a closed-but-registered channel (the canonical case: a DM that
|
|
3681
|
+
// woke the agent, since DMs register closed) OPENS it first, so typing
|
|
3682
|
+
// indicators, reaction machinery and inbound forwarding come alive with
|
|
3683
|
+
// the words. If the open fails, nothing goes out: a half-alive delivery
|
|
3684
|
+
// is worse than a loud marker. A channel decided closed is engaged even
|
|
3685
|
+
// while its transport is still open (a close that failed), as a reply
|
|
3686
|
+
// would engage it, wherever an open can succeed. Without the lifecycle
|
|
3687
|
+
// grant it can't, and a send opens before it publishes, so there a
|
|
3688
|
+
// channel whose transport is open keeps publishing.
|
|
3689
|
+
const engagesDecidedClosed = this.getDesiredState(entry.serverId, entry.descriptor.id) === 'closed' &&
|
|
3690
|
+
CapabilityGrant.of(server).has('channels.lifecycle');
|
|
3691
|
+
if (!entry.open || engagesDecidedClosed) {
|
|
3692
|
+
try {
|
|
3693
|
+
const { opened } = await this.openChannelNow(entry, openSource);
|
|
3694
|
+
entry = await this.waitForOpenChannel(entry);
|
|
3695
|
+
if (opened) {
|
|
3696
|
+
if (target.routed) {
|
|
3697
|
+
console.error(
|
|
3698
|
+
`[routeSpeech] ${conversationId}: locus ${logValue(entry.descriptor.id)} was closed — opened by delivery (speech implies engagement)`,
|
|
3699
|
+
);
|
|
3700
|
+
}
|
|
3701
|
+
this.emitTraceFn({
|
|
3702
|
+
type: 'mcpl:channel-opened-by-send',
|
|
3703
|
+
serverId: entry.serverId,
|
|
3704
|
+
channelId: entry.descriptor.id,
|
|
3705
|
+
});
|
|
3706
|
+
if (openSource === 'opened-by-delivery') {
|
|
3707
|
+
try {
|
|
3708
|
+
this.onChannelAutoOpened?.({
|
|
3709
|
+
conversationId,
|
|
3710
|
+
serverId: entry.serverId,
|
|
3711
|
+
source: 'opened-by-delivery',
|
|
3712
|
+
channels: [{ channelId: entry.descriptor.id, label: entry.descriptor.label }],
|
|
3713
|
+
});
|
|
3714
|
+
} catch (err) {
|
|
3715
|
+
console.error('onChannelAutoOpened (delivery) failed:', err);
|
|
3716
|
+
}
|
|
3282
3717
|
}
|
|
3283
3718
|
}
|
|
3284
3719
|
} catch (err) {
|
|
@@ -3308,7 +3743,9 @@ export class ChannelRegistry {
|
|
|
3308
3743
|
* Plain speech for one turn segment: publish `text` to the turn's frozen
|
|
3309
3744
|
* locus and report the PublishOutcome. A failure (or an uncertain outcome)
|
|
3310
3745
|
* is surfaced as a trace and through onRouteFailure, so the agent learns
|
|
3311
|
-
* its words did not (or may not have) reached anyone.
|
|
3746
|
+
* its words did not (or may not have) reached anyone. An ephemeral run's
|
|
3747
|
+
* speech with no locus is the exception: it is its caller's result, so it
|
|
3748
|
+
* returns as not delivered and surfaces nothing.
|
|
3312
3749
|
*/
|
|
3313
3750
|
async deliverSpeech(
|
|
3314
3751
|
conversationId: string,
|
|
@@ -3319,7 +3756,9 @@ export class ChannelRegistry {
|
|
|
3319
3756
|
* dispatch resolving live can read the NEXT turn's trigger state or a
|
|
3320
3757
|
* stale global default and land the reply in the wrong channel (item-3,
|
|
3321
3758
|
* PR #32, and the 2026-07-22 Sol DM misroute). Explicit `null` means
|
|
3322
|
-
* "this turn is pinned to no locus": fail loudly rather than guess
|
|
3759
|
+
* "this turn is pinned to no locus": fail loudly rather than guess,
|
|
3760
|
+
* except for an ephemeral run, whose speech is then its caller's
|
|
3761
|
+
* result. */
|
|
3323
3762
|
locusChannelId: string | null,
|
|
3324
3763
|
/** `heldOnFailure`: the caller holds a `failed` delivery's words as a
|
|
3325
3764
|
* draft with its own notice, so no failure marker is written for it.
|
|
@@ -3354,6 +3793,12 @@ export class ChannelRegistry {
|
|
|
3354
3793
|
return fail(null, reason, { status: 'failed', reason, at: Date.now() });
|
|
3355
3794
|
}
|
|
3356
3795
|
if (!locusChannelId) {
|
|
3796
|
+
// An ephemeral run with no channel of its own: its plain speech is its
|
|
3797
|
+
// caller's result (takesGlobalFallback). It returns as not delivered,
|
|
3798
|
+
// with its own reason, and writes no line, trace or notice.
|
|
3799
|
+
if (this.takesGlobalFallback?.(conversationId) === false) {
|
|
3800
|
+
return { status: 'failed', reason: "an ephemeral run's plain speech with no channel of its own is its caller's result", at: Date.now() };
|
|
3801
|
+
}
|
|
3357
3802
|
// The turn froze with no locus (no home, no triggering channel, no
|
|
3358
3803
|
// global inbound ever seen) — the agent was told its prose stays in
|
|
3359
3804
|
// the archive; honor that.
|
|
@@ -3399,7 +3844,10 @@ export class ChannelRegistry {
|
|
|
3399
3844
|
};
|
|
3400
3845
|
}
|
|
3401
3846
|
|
|
3402
|
-
private async handleToolPublish(
|
|
3847
|
+
private async handleToolPublish(
|
|
3848
|
+
input: { channelId?: string; content?: string; text?: string },
|
|
3849
|
+
origin?: ChannelToolOrigin,
|
|
3850
|
+
): Promise<ToolResult> {
|
|
3403
3851
|
// Resolve content: accept both `content` and `text` (backward compat)
|
|
3404
3852
|
const messageText = input.content ?? input.text;
|
|
3405
3853
|
if (!messageText) {
|
|
@@ -3410,12 +3858,17 @@ export class ChannelRegistry {
|
|
|
3410
3858
|
};
|
|
3411
3859
|
}
|
|
3412
3860
|
|
|
3413
|
-
// Resolve channelId: default to most recent incoming channel
|
|
3414
|
-
|
|
3861
|
+
// Resolve channelId: default to the most recent incoming channel, which an
|
|
3862
|
+
// ephemeral run never takes (takesGlobalFallback), so it names one.
|
|
3863
|
+
const caller = origin?.kind === 'agent' ? origin.agentName : undefined;
|
|
3864
|
+
const channelId = input.channelId ?? (caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel);
|
|
3415
3865
|
if (!channelId) {
|
|
3416
3866
|
return {
|
|
3417
3867
|
success: false,
|
|
3418
|
-
error:
|
|
3868
|
+
error: caller !== undefined && this.takesGlobalFallback?.(caller) === false
|
|
3869
|
+
? 'Nothing was published: name the channel with channelId. A subagent has no default channel, since its plain '
|
|
3870
|
+
+ 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.'
|
|
3871
|
+
: 'No channelId specified and no default channel available',
|
|
3419
3872
|
isError: true,
|
|
3420
3873
|
};
|
|
3421
3874
|
}
|