@animalabs/agent-framework 0.6.4 → 0.6.6
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 +5 -0
- package/dist/src/agent.d.ts +18 -1
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +221 -2
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +59 -2
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +687 -229
- 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 +27 -4
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +34 -15
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +299 -67
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +1 -0
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +1 -0
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +3 -1
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +4 -0
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +43 -12
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js +1 -0
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/types/agent.d.ts +15 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +1 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +9 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +2 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/activation-request-sanitize.test.d.ts +2 -0
- package/dist/test/activation-request-sanitize.test.d.ts.map +1 -0
- package/dist/test/activation-request-sanitize.test.js +89 -0
- package/dist/test/activation-request-sanitize.test.js.map +1 -0
- package/dist/test/agent-runtime-settings.test.d.ts +2 -0
- package/dist/test/agent-runtime-settings.test.d.ts.map +1 -0
- package/dist/test/agent-runtime-settings.test.js +120 -0
- package/dist/test/agent-runtime-settings.test.js.map +1 -0
- package/dist/test/channel-registry-routing.test.js +5 -4
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/conversation-fork-injection-scoping.test.js +12 -1
- package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
- package/dist/test/ephemeral-settle.test.d.ts +2 -0
- package/dist/test/ephemeral-settle.test.d.ts.map +1 -0
- package/dist/test/ephemeral-settle.test.js +351 -0
- package/dist/test/ephemeral-settle.test.js.map +1 -0
- package/dist/test/event-gate.test.js +21 -1
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/helpers/mock-membrane.d.ts +13 -1
- package/dist/test/helpers/mock-membrane.d.ts.map +1 -1
- package/dist/test/helpers/mock-membrane.js +8 -1
- package/dist/test/helpers/mock-membrane.js.map +1 -1
- package/dist/test/maintenance-timer.test.js +52 -1
- package/dist/test/maintenance-timer.test.js.map +1 -1
- package/dist/test/mcpl-gate-roundtrip.test.js +138 -6
- package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
- package/dist/test/overbudget-drain-breaker.test.d.ts +2 -0
- package/dist/test/overbudget-drain-breaker.test.d.ts.map +1 -0
- package/dist/test/overbudget-drain-breaker.test.js +157 -0
- package/dist/test/overbudget-drain-breaker.test.js.map +1 -0
- package/dist/test/present-while-acting.test.d.ts +18 -0
- package/dist/test/present-while-acting.test.d.ts.map +1 -0
- package/dist/test/present-while-acting.test.js +267 -0
- package/dist/test/present-while-acting.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +38 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/agent.ts +244 -3
- package/src/framework.ts +763 -235
- package/src/gate/event-gate.ts +38 -4
- package/src/mcpl/channel-registry.ts +372 -76
- package/src/mcpl/push-handler.ts +2 -0
- package/src/mcpl/server-connection.ts +10 -0
- package/src/mcpl/types.ts +49 -12
- package/src/types/agent.ts +18 -0
- package/src/types/events.ts +1 -1
- package/src/types/framework.ts +10 -0
- package/src/types/trace.ts +2 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Responsibilities:
|
|
8
8
|
* - Register/unregister channel descriptors from MCPL servers
|
|
9
|
-
* -
|
|
9
|
+
* - Reconcile actual channel state to Chronicle-backed desired state
|
|
10
10
|
* - Route incoming messages to the processing queue
|
|
11
11
|
* - Manage typing indicator timers (7s interval for Discord compatibility)
|
|
12
12
|
* - Expose synthesized tools: channel_list, channel_open, channel_close, channel_publish
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive)
|
|
17
17
|
// ============================================================================
|
|
18
18
|
const TYPING_INTERVAL_MS = 7_000;
|
|
19
|
+
const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle';
|
|
19
20
|
function shallowEqualRecord(a, b) {
|
|
20
21
|
if (a === b)
|
|
21
22
|
return true;
|
|
@@ -78,15 +79,45 @@ const CHANNEL_TOOL_DEFINITIONS = [
|
|
|
78
79
|
},
|
|
79
80
|
{
|
|
80
81
|
name: 'channel_open',
|
|
81
|
-
description: 'Open a channel to start receiving
|
|
82
|
+
description: 'Open a channel to start receiving its ordinary ongoing traffic. The MCPL ' +
|
|
83
|
+
'integration performs its own subscribe/join/attach operation. Optionally request ' +
|
|
84
|
+
'history preceding the message that invited you into the channel.',
|
|
82
85
|
inputSchema: {
|
|
83
86
|
type: 'object',
|
|
84
87
|
properties: {
|
|
85
88
|
channelId: { type: 'string', description: 'ID of the channel to open' },
|
|
89
|
+
serverId: { type: 'string', description: 'Owning MCPL server; required only when channelId is ambiguous.' },
|
|
90
|
+
backscroll: {
|
|
91
|
+
type: 'number',
|
|
92
|
+
description: 'Number of earlier messages to return while opening (0-500; default 0).',
|
|
93
|
+
},
|
|
94
|
+
beforeMessageId: {
|
|
95
|
+
type: 'string',
|
|
96
|
+
description: 'Anchor message from the closed-channel notice; it is excluded from backscroll.',
|
|
97
|
+
},
|
|
86
98
|
},
|
|
87
99
|
required: ['channelId'],
|
|
88
100
|
},
|
|
89
101
|
},
|
|
102
|
+
{
|
|
103
|
+
name: 'channel_decline',
|
|
104
|
+
description: 'Deliberately remain closed after being addressed in a closed channel. Optionally ' +
|
|
105
|
+
'post a public acknowledgment through the MCPL integration. Acknowledgment is ' +
|
|
106
|
+
'opt-in; omitting it declines silently.',
|
|
107
|
+
inputSchema: {
|
|
108
|
+
type: 'object',
|
|
109
|
+
properties: {
|
|
110
|
+
channelId: { type: 'string', description: 'Channel from the invitation notice.' },
|
|
111
|
+
serverId: { type: 'string', description: 'Owning MCPL server from the invitation notice.' },
|
|
112
|
+
messageId: { type: 'string', description: 'Triggering message to acknowledge.' },
|
|
113
|
+
acknowledge: {
|
|
114
|
+
type: 'string',
|
|
115
|
+
description: 'Optional surface value such as 👀. Omit for a silent decline.',
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
required: ['channelId', 'messageId'],
|
|
119
|
+
},
|
|
120
|
+
},
|
|
90
121
|
{
|
|
91
122
|
name: 'channel_close',
|
|
92
123
|
description: 'Close a channel to stop receiving messages',
|
|
@@ -94,6 +125,7 @@ const CHANNEL_TOOL_DEFINITIONS = [
|
|
|
94
125
|
type: 'object',
|
|
95
126
|
properties: {
|
|
96
127
|
channelId: { type: 'string', description: 'ID of the channel to close' },
|
|
128
|
+
serverId: { type: 'string', description: 'Owning MCPL server; required only when channelId is ambiguous.' },
|
|
97
129
|
},
|
|
98
130
|
required: ['channelId'],
|
|
99
131
|
},
|
|
@@ -160,6 +192,7 @@ export class ChannelRegistry {
|
|
|
160
192
|
onRouteFailure;
|
|
161
193
|
homeChannelResolver;
|
|
162
194
|
activeChannelResolver;
|
|
195
|
+
store;
|
|
163
196
|
/** Registered channels, keyed by `{serverId}:{channelId}`. */
|
|
164
197
|
channels = new Map();
|
|
165
198
|
/** Most recent incoming channel ID — used for speech routing / default publish. */
|
|
@@ -172,12 +205,11 @@ export class ChannelRegistry {
|
|
|
172
205
|
/** Per-channel typing metadata — carried on the 7s refresh so the target
|
|
173
206
|
* server keeps getting the same routing hints (e.g. Zulip topic). */
|
|
174
207
|
typingMetadata = new Map();
|
|
175
|
-
/**
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
subscriptionPolicies = new Map();
|
|
208
|
+
/** Chronicle-projected desired lifecycle state, keyed by server + channel. */
|
|
209
|
+
desiredStates = new Map();
|
|
210
|
+
/** One-time migration inputs from the retired recipe auto-open policy. */
|
|
211
|
+
legacyPolicies = new Map();
|
|
212
|
+
migratedLegacyPolicies = new Set();
|
|
181
213
|
constructor(serverRegistry, featureSetManager, pushEventFn, emitTraceFn, options) {
|
|
182
214
|
this.serverRegistry = serverRegistry;
|
|
183
215
|
this.featureSetManager = featureSetManager;
|
|
@@ -188,13 +220,16 @@ export class ChannelRegistry {
|
|
|
188
220
|
this.onRouteFailure = options?.onRouteFailure;
|
|
189
221
|
this.homeChannelResolver = options?.homeChannelResolver;
|
|
190
222
|
this.activeChannelResolver = options?.activeChannelResolver;
|
|
223
|
+
this.store = options?.store;
|
|
224
|
+
this.initializeLifecycleStore();
|
|
191
225
|
}
|
|
192
226
|
/**
|
|
193
|
-
*
|
|
194
|
-
* when each MCPL server is registered.
|
|
227
|
+
* Supply a legacy recipe policy for one-time migration into Chronicle.
|
|
195
228
|
*/
|
|
196
229
|
setSubscriptionPolicy(serverId, policy) {
|
|
197
|
-
|
|
230
|
+
// Backward-compatible recipe ingestion only. The policy is consumed once
|
|
231
|
+
// to seed Chronicle, then never applied to newly discovered channels.
|
|
232
|
+
this.legacyPolicies.set(serverId, policy);
|
|
198
233
|
}
|
|
199
234
|
// ==========================================================================
|
|
200
235
|
// Handler Methods (called from framework.ts wireMcplEvents)
|
|
@@ -202,7 +237,7 @@ export class ChannelRegistry {
|
|
|
202
237
|
/**
|
|
203
238
|
* Handle `channels/register` from a server.
|
|
204
239
|
*
|
|
205
|
-
* Registers
|
|
240
|
+
* Registers descriptors and reconciles them to durable desired state.
|
|
206
241
|
*/
|
|
207
242
|
async handleRegister(serverId, params, responder) {
|
|
208
243
|
const registeredIds = [];
|
|
@@ -215,12 +250,14 @@ export class ChannelRegistry {
|
|
|
215
250
|
});
|
|
216
251
|
registeredIds.push(channel.id);
|
|
217
252
|
}
|
|
218
|
-
// Respond before
|
|
253
|
+
// Respond before reconciliation — the server blocks on this response and
|
|
219
254
|
// can't process channels/open until it arrives.
|
|
220
255
|
const result = { registered: registeredIds };
|
|
221
256
|
responder?.respond(result);
|
|
222
|
-
//
|
|
223
|
-
|
|
257
|
+
// One-time migration from the retired recipe policy, then reconcile the
|
|
258
|
+
// server to Chronicle-backed desired state.
|
|
259
|
+
this.migrateLegacyPolicy(serverId, params.channels);
|
|
260
|
+
await this.reconcileChannels(serverId, params.channels);
|
|
224
261
|
this.emitTraceFn({
|
|
225
262
|
type: 'mcpl:channels-register',
|
|
226
263
|
serverId,
|
|
@@ -231,7 +268,7 @@ export class ChannelRegistry {
|
|
|
231
268
|
/**
|
|
232
269
|
* Handle `channels/changed` notification from a server.
|
|
233
270
|
*
|
|
234
|
-
* Processes added (register +
|
|
271
|
+
* Processes added (register + reconcile), removed (delete + stop typing),
|
|
235
272
|
* and updated (replace descriptor) channels.
|
|
236
273
|
*/
|
|
237
274
|
async handleChanged(serverId, params) {
|
|
@@ -253,7 +290,7 @@ export class ChannelRegistry {
|
|
|
253
290
|
}
|
|
254
291
|
}
|
|
255
292
|
}
|
|
256
|
-
// Process added channels (register +
|
|
293
|
+
// Process added channels (register + reconcile durable desired state)
|
|
257
294
|
if (params.added) {
|
|
258
295
|
for (const channel of params.added) {
|
|
259
296
|
const key = `${serverId}:${channel.id}`;
|
|
@@ -263,7 +300,7 @@ export class ChannelRegistry {
|
|
|
263
300
|
open: false,
|
|
264
301
|
});
|
|
265
302
|
}
|
|
266
|
-
await this.
|
|
303
|
+
await this.reconcileChannels(serverId, params.added);
|
|
267
304
|
}
|
|
268
305
|
this.emitTraceFn({
|
|
269
306
|
type: 'mcpl:channels-changed',
|
|
@@ -323,6 +360,13 @@ export class ChannelRegistry {
|
|
|
323
360
|
label: channelLabel,
|
|
324
361
|
});
|
|
325
362
|
}
|
|
363
|
+
else {
|
|
364
|
+
// A server sending channels/incoming is authoritative evidence that
|
|
365
|
+
// the transport is actually open. This repairs transient status only;
|
|
366
|
+
// durable desired state still changes exclusively through lifecycle
|
|
367
|
+
// operations.
|
|
368
|
+
this.channels.get(incomingKey).open = true;
|
|
369
|
+
}
|
|
326
370
|
// Determine whether to trigger inference
|
|
327
371
|
let triggerInference = true;
|
|
328
372
|
if (this.shouldTriggerInference) {
|
|
@@ -374,8 +418,9 @@ export class ChannelRegistry {
|
|
|
374
418
|
});
|
|
375
419
|
}
|
|
376
420
|
/**
|
|
377
|
-
* Ensure a channel is registered
|
|
378
|
-
*
|
|
421
|
+
* Ensure a channel is registered so it can serve as an outbound routing
|
|
422
|
+
* locus. A push event from a closed channel must never mutate lifecycle
|
|
423
|
+
* state: direct-address events are intentionally usable without subscribing.
|
|
379
424
|
*
|
|
380
425
|
* Mirrors the lazy-registration inside handleIncoming(), but for channels
|
|
381
426
|
* that only ever arrive as push/events rather than channels/incoming — the
|
|
@@ -385,15 +430,11 @@ export class ChannelRegistry {
|
|
|
385
430
|
* silently dropped even though the inbound message proves the channel is
|
|
386
431
|
* reachable (item-3 redux, DM sub-case). Idempotent: a later authoritative
|
|
387
432
|
* channels/register or channels/changed overwrites this descriptor with the
|
|
388
|
-
* richer one
|
|
433
|
+
* richer one.
|
|
389
434
|
*/
|
|
390
435
|
ensureChannelRegistered(serverId, channelId, label) {
|
|
391
436
|
const existing = this.findChannelEntry(channelId);
|
|
392
437
|
if (existing) {
|
|
393
|
-
if (!existing.open) {
|
|
394
|
-
existing.open = true;
|
|
395
|
-
this.emitTraceFn({ type: 'mcpl:channel-opened', serverId: existing.serverId, channelId });
|
|
396
|
-
}
|
|
397
438
|
return;
|
|
398
439
|
}
|
|
399
440
|
const key = `${serverId}:${channelId}`;
|
|
@@ -406,7 +447,7 @@ export class ChannelRegistry {
|
|
|
406
447
|
direction: 'bidirectional',
|
|
407
448
|
metadata: { lazyRegistered: true },
|
|
408
449
|
},
|
|
409
|
-
open:
|
|
450
|
+
open: false,
|
|
410
451
|
});
|
|
411
452
|
this.emitTraceFn({
|
|
412
453
|
type: 'mcpl:channel-lazy-registered',
|
|
@@ -519,6 +560,12 @@ export class ChannelRegistry {
|
|
|
519
560
|
getDescriptor(channelId) {
|
|
520
561
|
return this.findChannelEntry(channelId)?.descriptor;
|
|
521
562
|
}
|
|
563
|
+
isChannelOpen(channelId) {
|
|
564
|
+
return this.findChannelEntry(channelId)?.open === true;
|
|
565
|
+
}
|
|
566
|
+
getDesiredState(serverId, channelId) {
|
|
567
|
+
return this.desiredStates.get(this.lifecycleKey(serverId, channelId));
|
|
568
|
+
}
|
|
522
569
|
/**
|
|
523
570
|
* Get all open channels.
|
|
524
571
|
*/
|
|
@@ -551,6 +598,8 @@ export class ChannelRegistry {
|
|
|
551
598
|
return this.handleToolOpen(input);
|
|
552
599
|
case 'channel_close':
|
|
553
600
|
return this.handleToolClose(input);
|
|
601
|
+
case 'channel_decline':
|
|
602
|
+
return this.handleToolDecline(input);
|
|
554
603
|
case 'channel_publish':
|
|
555
604
|
return this.handleToolPublish(input);
|
|
556
605
|
case 'think':
|
|
@@ -626,7 +675,7 @@ export class ChannelRegistry {
|
|
|
626
675
|
this.defaultPublishThreadId = undefined;
|
|
627
676
|
}
|
|
628
677
|
}
|
|
629
|
-
|
|
678
|
+
// Desired state and migration markers deliberately survive disconnects.
|
|
630
679
|
}
|
|
631
680
|
/**
|
|
632
681
|
* Stop all typing intervals and clear all channel registrations.
|
|
@@ -645,59 +694,137 @@ export class ChannelRegistry {
|
|
|
645
694
|
this.defaultPublishThreadId = undefined;
|
|
646
695
|
}
|
|
647
696
|
// ==========================================================================
|
|
648
|
-
// Private:
|
|
697
|
+
// Private: Durable desired state and reconciliation
|
|
649
698
|
// ==========================================================================
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
699
|
+
lifecycleKey(serverId, channelId) {
|
|
700
|
+
return `${serverId}\u0000${channelId}`;
|
|
701
|
+
}
|
|
702
|
+
initializeLifecycleStore() {
|
|
703
|
+
if (!this.store)
|
|
653
704
|
return;
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
705
|
+
try {
|
|
706
|
+
this.store.registerState({ id: CHANNEL_LIFECYCLE_LOG_ID, strategy: 'append_log' });
|
|
707
|
+
}
|
|
708
|
+
catch (error) {
|
|
709
|
+
if (!(error instanceof Error) || !error.message.includes('State already exists')) {
|
|
710
|
+
throw error;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const raw = this.store.getStateJson(CHANNEL_LIFECYCLE_LOG_ID);
|
|
714
|
+
if (!Array.isArray(raw))
|
|
662
715
|
return;
|
|
716
|
+
for (const item of raw) {
|
|
717
|
+
if (!item || typeof item !== 'object')
|
|
718
|
+
continue;
|
|
719
|
+
const event = item;
|
|
720
|
+
if (typeof event.serverId !== 'string')
|
|
721
|
+
continue;
|
|
722
|
+
if (event.kind === 'desired-state' &&
|
|
723
|
+
typeof event.channelId === 'string' &&
|
|
724
|
+
(event.desired === 'open' || event.desired === 'closed')) {
|
|
725
|
+
this.desiredStates.set(this.lifecycleKey(event.serverId, event.channelId), event.desired);
|
|
726
|
+
}
|
|
727
|
+
else if (event.kind === 'legacy-policy-migrated') {
|
|
728
|
+
this.migratedLegacyPolicies.add(event.serverId);
|
|
729
|
+
}
|
|
663
730
|
}
|
|
664
|
-
|
|
665
|
-
|
|
731
|
+
}
|
|
732
|
+
appendLifecycleEvent(event) {
|
|
733
|
+
this.store?.appendToStateJson(CHANNEL_LIFECYCLE_LOG_ID, event);
|
|
734
|
+
}
|
|
735
|
+
setDesiredState(serverId, channelId, desired, source) {
|
|
736
|
+
const key = this.lifecycleKey(serverId, channelId);
|
|
737
|
+
if (this.desiredStates.get(key) === desired)
|
|
738
|
+
return;
|
|
739
|
+
this.desiredStates.set(key, desired);
|
|
740
|
+
this.appendLifecycleEvent({
|
|
741
|
+
kind: 'desired-state',
|
|
742
|
+
serverId,
|
|
743
|
+
channelId,
|
|
744
|
+
desired,
|
|
745
|
+
source,
|
|
746
|
+
timestamp: new Date().toISOString(),
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Consume the old recipe policy exactly once. It seeds Chronicle for
|
|
751
|
+
* existing deployments, but is not an ongoing admission policy: channels
|
|
752
|
+
* discovered later use their server bootstrap preference, otherwise closed.
|
|
753
|
+
*/
|
|
754
|
+
migrateLegacyPolicy(serverId, channels) {
|
|
755
|
+
if (this.migratedLegacyPolicies.has(serverId))
|
|
756
|
+
return;
|
|
757
|
+
const policy = this.legacyPolicies.get(serverId) ?? 'manual';
|
|
758
|
+
const allowList = Array.isArray(policy) ? new Set(policy) : undefined;
|
|
666
759
|
for (const channel of channels) {
|
|
667
|
-
if (
|
|
760
|
+
if (this.getDesiredState(serverId, channel.id))
|
|
668
761
|
continue;
|
|
762
|
+
const desired = channel.initiallyOpen === true ||
|
|
763
|
+
policy === 'auto' || allowList?.has(channel.id)
|
|
764
|
+
? 'open'
|
|
765
|
+
: 'closed';
|
|
766
|
+
this.setDesiredState(serverId, channel.id, desired, 'legacy-recipe-migration');
|
|
767
|
+
}
|
|
768
|
+
this.migratedLegacyPolicies.add(serverId);
|
|
769
|
+
this.appendLifecycleEvent({
|
|
770
|
+
kind: 'legacy-policy-migrated',
|
|
771
|
+
serverId,
|
|
772
|
+
source: Array.isArray(policy) ? 'allow-list' : policy,
|
|
773
|
+
timestamp: new Date().toISOString(),
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
ensureInitialDesiredState(serverId, channel) {
|
|
777
|
+
if (this.getDesiredState(serverId, channel.id))
|
|
778
|
+
return;
|
|
779
|
+
this.setDesiredState(serverId, channel.id, channel.initiallyOpen === true ? 'open' : 'closed', channel.initiallyOpen === true ? 'server-bootstrap' : 'default-closed');
|
|
780
|
+
}
|
|
781
|
+
async reconcileChannels(serverId, channels) {
|
|
782
|
+
const server = this.serverRegistry.getServer(serverId);
|
|
783
|
+
if (!server)
|
|
784
|
+
return;
|
|
785
|
+
for (const channel of channels) {
|
|
786
|
+
this.ensureInitialDesiredState(serverId, channel);
|
|
669
787
|
const key = `${serverId}:${channel.id}`;
|
|
788
|
+
const desired = this.getDesiredState(serverId, channel.id);
|
|
789
|
+
const entry = this.channels.get(key);
|
|
790
|
+
if (desired !== 'open') {
|
|
791
|
+
try {
|
|
792
|
+
await server.sendChannelsClose({ channelId: channel.id });
|
|
793
|
+
if (entry)
|
|
794
|
+
entry.open = false;
|
|
795
|
+
}
|
|
796
|
+
catch (err) {
|
|
797
|
+
this.emitTraceFn({
|
|
798
|
+
type: 'mcpl:channel-reconcile-failed',
|
|
799
|
+
serverId,
|
|
800
|
+
channelId: channel.id,
|
|
801
|
+
desired,
|
|
802
|
+
error: err.message,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
670
807
|
try {
|
|
671
808
|
await server.sendChannelsOpen({
|
|
809
|
+
channelId: channel.id,
|
|
672
810
|
type: channel.type,
|
|
673
811
|
address: channel.address,
|
|
674
812
|
});
|
|
675
|
-
|
|
676
|
-
if (entry) {
|
|
813
|
+
if (entry)
|
|
677
814
|
entry.open = true;
|
|
678
|
-
}
|
|
679
|
-
opened++;
|
|
680
815
|
}
|
|
681
816
|
catch (err) {
|
|
817
|
+
if (entry)
|
|
818
|
+
entry.open = false;
|
|
682
819
|
this.emitTraceFn({
|
|
683
|
-
type: 'mcpl:channel-
|
|
820
|
+
type: 'mcpl:channel-reconcile-failed',
|
|
684
821
|
serverId,
|
|
685
822
|
channelId: channel.id,
|
|
823
|
+
desired,
|
|
686
824
|
error: err.message,
|
|
687
825
|
});
|
|
688
826
|
}
|
|
689
827
|
}
|
|
690
|
-
// Surface the common typo failure mode: allow-list set but nothing
|
|
691
|
-
// matched. Without this trace a one-char typo in a channel id looks
|
|
692
|
-
// identical to "MCPL didn't register the channel".
|
|
693
|
-
if (allowList && opened === 0 && channels.length > 0) {
|
|
694
|
-
this.emitTraceFn({
|
|
695
|
-
type: 'mcpl:channels-allow-list-empty',
|
|
696
|
-
serverId,
|
|
697
|
-
allowList: [...allowList],
|
|
698
|
-
registered: channels.map(c => c.id),
|
|
699
|
-
});
|
|
700
|
-
}
|
|
701
828
|
}
|
|
702
829
|
// ==========================================================================
|
|
703
830
|
// Private: Typing notification
|
|
@@ -734,6 +861,22 @@ export class ChannelRegistry {
|
|
|
734
861
|
}
|
|
735
862
|
return undefined;
|
|
736
863
|
}
|
|
864
|
+
resolveToolChannelEntry(channelId, serverId) {
|
|
865
|
+
const matches = [...this.channels.values()].filter((entry) => entry.descriptor.id === channelId && (!serverId || entry.serverId === serverId));
|
|
866
|
+
if (matches.length === 0) {
|
|
867
|
+
return {
|
|
868
|
+
error: serverId
|
|
869
|
+
? `Channel not found: ${channelId} on server ${serverId}`
|
|
870
|
+
: `Channel not found: ${channelId}`,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
if (matches.length > 1) {
|
|
874
|
+
return {
|
|
875
|
+
error: `Channel id is ambiguous across MCPL servers: ${channelId}. Include serverId.`,
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
return { entry: matches[0] };
|
|
879
|
+
}
|
|
737
880
|
/**
|
|
738
881
|
* Find the composite key for a channel by its channelId.
|
|
739
882
|
*/
|
|
@@ -757,6 +900,7 @@ export class ChannelRegistry {
|
|
|
757
900
|
label: entry.descriptor.label,
|
|
758
901
|
direction: entry.descriptor.direction,
|
|
759
902
|
open: entry.open,
|
|
903
|
+
desired: this.getDesiredState(entry.serverId, entry.descriptor.id) ?? 'unknown',
|
|
760
904
|
serverId: entry.serverId,
|
|
761
905
|
});
|
|
762
906
|
}
|
|
@@ -766,20 +910,23 @@ export class ChannelRegistry {
|
|
|
766
910
|
};
|
|
767
911
|
}
|
|
768
912
|
async handleToolOpen(input) {
|
|
769
|
-
const
|
|
913
|
+
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
|
|
914
|
+
const entry = resolved.entry;
|
|
770
915
|
if (!entry) {
|
|
771
916
|
return {
|
|
772
917
|
success: false,
|
|
773
|
-
error:
|
|
918
|
+
error: resolved.error,
|
|
774
919
|
isError: true,
|
|
775
920
|
};
|
|
776
921
|
}
|
|
777
|
-
|
|
922
|
+
const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open';
|
|
923
|
+
if (entry.open && alreadyDesiredOpen && !input.backscroll) {
|
|
778
924
|
return {
|
|
779
925
|
success: true,
|
|
780
926
|
data: { channelId: input.channelId, status: 'already open' },
|
|
781
927
|
};
|
|
782
928
|
}
|
|
929
|
+
this.setDesiredState(entry.serverId, input.channelId, 'open', 'agent-tool');
|
|
783
930
|
const server = this.serverRegistry.getServer(entry.serverId);
|
|
784
931
|
if (!server) {
|
|
785
932
|
return {
|
|
@@ -789,14 +936,27 @@ export class ChannelRegistry {
|
|
|
789
936
|
};
|
|
790
937
|
}
|
|
791
938
|
try {
|
|
792
|
-
|
|
939
|
+
const history = input.backscroll
|
|
940
|
+
? {
|
|
941
|
+
limit: Math.max(0, Math.min(500, Math.floor(input.backscroll))),
|
|
942
|
+
...(input.beforeMessageId ? { beforeMessageId: input.beforeMessageId } : {}),
|
|
943
|
+
}
|
|
944
|
+
: undefined;
|
|
945
|
+
const result = await server.sendChannelsOpen({
|
|
946
|
+
channelId: entry.descriptor.id,
|
|
793
947
|
type: entry.descriptor.type,
|
|
794
948
|
address: entry.descriptor.address,
|
|
949
|
+
...(history ? { history } : {}),
|
|
795
950
|
});
|
|
796
951
|
entry.open = true;
|
|
797
952
|
return {
|
|
798
953
|
success: true,
|
|
799
|
-
data: {
|
|
954
|
+
data: {
|
|
955
|
+
channelId: input.channelId,
|
|
956
|
+
status: alreadyDesiredOpen ? 'reconciled' : 'opened',
|
|
957
|
+
...(result.history ? { history: result.history } : {}),
|
|
958
|
+
...(result.historyTruncated ? { historyTruncated: true } : {}),
|
|
959
|
+
},
|
|
800
960
|
};
|
|
801
961
|
}
|
|
802
962
|
catch (err) {
|
|
@@ -808,20 +968,24 @@ export class ChannelRegistry {
|
|
|
808
968
|
}
|
|
809
969
|
}
|
|
810
970
|
async handleToolClose(input) {
|
|
811
|
-
const
|
|
971
|
+
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
|
|
972
|
+
const entry = resolved.entry;
|
|
812
973
|
if (!entry) {
|
|
813
974
|
return {
|
|
814
975
|
success: false,
|
|
815
|
-
error:
|
|
976
|
+
error: resolved.error,
|
|
816
977
|
isError: true,
|
|
817
978
|
};
|
|
818
979
|
}
|
|
819
|
-
|
|
980
|
+
const alreadyDesiredClosed = this.getDesiredState(entry.serverId, input.channelId) === 'closed';
|
|
981
|
+
if (!entry.open && alreadyDesiredClosed) {
|
|
820
982
|
return {
|
|
821
983
|
success: true,
|
|
822
984
|
data: { channelId: input.channelId, status: 'already closed' },
|
|
823
985
|
};
|
|
824
986
|
}
|
|
987
|
+
this.setDesiredState(entry.serverId, input.channelId, 'closed', 'agent-tool');
|
|
988
|
+
this.stopTyping(input.channelId);
|
|
825
989
|
const server = this.serverRegistry.getServer(entry.serverId);
|
|
826
990
|
if (!server) {
|
|
827
991
|
return {
|
|
@@ -833,7 +997,6 @@ export class ChannelRegistry {
|
|
|
833
997
|
try {
|
|
834
998
|
await server.sendChannelsClose({ channelId: input.channelId });
|
|
835
999
|
entry.open = false;
|
|
836
|
-
this.stopTyping(input.channelId);
|
|
837
1000
|
return {
|
|
838
1001
|
success: true,
|
|
839
1002
|
data: { channelId: input.channelId, status: 'closed' },
|
|
@@ -847,6 +1010,69 @@ export class ChannelRegistry {
|
|
|
847
1010
|
};
|
|
848
1011
|
}
|
|
849
1012
|
}
|
|
1013
|
+
async handleToolDecline(input) {
|
|
1014
|
+
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
|
|
1015
|
+
const entry = resolved.entry;
|
|
1016
|
+
if (!entry) {
|
|
1017
|
+
return { success: false, error: resolved.error, isError: true };
|
|
1018
|
+
}
|
|
1019
|
+
if (entry.open || this.getDesiredState(entry.serverId, input.channelId) === 'open') {
|
|
1020
|
+
return {
|
|
1021
|
+
success: false,
|
|
1022
|
+
error: `Channel is already open: ${input.channelId}. Use channel_close to leave it.`,
|
|
1023
|
+
isError: true,
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
// The lifecycle decision is authoritative even if the optional public
|
|
1027
|
+
// acknowledgment cannot be rendered by the surface.
|
|
1028
|
+
this.setDesiredState(entry.serverId, input.channelId, 'closed', 'invitation-declined');
|
|
1029
|
+
let acknowledged = false;
|
|
1030
|
+
let representation;
|
|
1031
|
+
let acknowledgmentError;
|
|
1032
|
+
if (input.acknowledge) {
|
|
1033
|
+
const server = this.serverRegistry.getServer(entry.serverId);
|
|
1034
|
+
if (!server) {
|
|
1035
|
+
acknowledgmentError = `Server not found: ${entry.serverId}`;
|
|
1036
|
+
}
|
|
1037
|
+
else {
|
|
1038
|
+
try {
|
|
1039
|
+
const result = await server.sendChannelsAcknowledge({
|
|
1040
|
+
channelId: input.channelId,
|
|
1041
|
+
messageId: input.messageId,
|
|
1042
|
+
intent: 'seen-not-opening',
|
|
1043
|
+
value: input.acknowledge,
|
|
1044
|
+
});
|
|
1045
|
+
acknowledged = result.acknowledged;
|
|
1046
|
+
representation = result.representation;
|
|
1047
|
+
if (!acknowledged) {
|
|
1048
|
+
acknowledgmentError = result.reason ??
|
|
1049
|
+
'The channel integration could not post the acknowledgment.';
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
catch (error) {
|
|
1053
|
+
acknowledgmentError = error.message;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
this.appendLifecycleEvent({
|
|
1058
|
+
kind: 'invitation-declined',
|
|
1059
|
+
serverId: entry.serverId,
|
|
1060
|
+
channelId: input.channelId,
|
|
1061
|
+
messageId: input.messageId,
|
|
1062
|
+
acknowledgment: representation ?? input.acknowledge,
|
|
1063
|
+
timestamp: new Date().toISOString(),
|
|
1064
|
+
});
|
|
1065
|
+
return {
|
|
1066
|
+
success: true,
|
|
1067
|
+
data: {
|
|
1068
|
+
channelId: input.channelId,
|
|
1069
|
+
status: 'remained closed',
|
|
1070
|
+
acknowledged,
|
|
1071
|
+
...(representation ? { representation } : {}),
|
|
1072
|
+
...(acknowledgmentError ? { acknowledgmentError } : {}),
|
|
1073
|
+
},
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
850
1076
|
/**
|
|
851
1077
|
* Handle the synthesized `think` tool — a private reasoning scratchpad. It
|
|
852
1078
|
* sends nothing, and (unlike before) does NOT silence the turn: trailing
|
|
@@ -874,6 +1100,12 @@ export class ChannelRegistry {
|
|
|
874
1100
|
handleToolSkipReply(input) {
|
|
875
1101
|
return {
|
|
876
1102
|
success: true,
|
|
1103
|
+
// The note says "ended the turn" — make it TRUE. Without endTurn the
|
|
1104
|
+
// framework resumes the stream after the tool result, and a model with
|
|
1105
|
+
// nothing to say (told its turn already ended) just calls skip_reply
|
|
1106
|
+
// again: observed as a 40+ round skip_reply loop on Fable 5, burning a
|
|
1107
|
+
// round-trip + ~70 tokens per iteration until something kills the turn.
|
|
1108
|
+
endTurn: true,
|
|
877
1109
|
data: {
|
|
878
1110
|
skipped: true,
|
|
879
1111
|
note: 'Ended the turn without replying — nothing was sent to any channel.',
|