@animalabs/agent-framework 0.6.5 → 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/dist/src/agent.d.ts +18 -1
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +201 -2
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +15 -1
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +423 -86
- 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/trace.d.ts +2 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- 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/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 +3 -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/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 +222 -3
- package/src/framework.ts +468 -92
- 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/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
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { ContentBlock } from '@animalabs/membrane';
|
|
17
|
+
import type { JsStore } from '@animalabs/chronicle';
|
|
17
18
|
|
|
18
19
|
import type {
|
|
19
20
|
ChannelDescriptor,
|
|
@@ -25,6 +26,8 @@ import type {
|
|
|
25
26
|
ChannelsIncomingResult,
|
|
26
27
|
ChannelIncomingMessageResult,
|
|
27
28
|
ChannelsPublishParams,
|
|
29
|
+
ChannelsOpenResult,
|
|
30
|
+
ChannelHistoryRequest,
|
|
28
31
|
McplContentBlock,
|
|
29
32
|
} from './types.js';
|
|
30
33
|
|
|
@@ -37,6 +40,20 @@ import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js
|
|
|
37
40
|
// ============================================================================
|
|
38
41
|
|
|
39
42
|
const TYPING_INTERVAL_MS = 7_000;
|
|
43
|
+
const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle';
|
|
44
|
+
|
|
45
|
+
type DesiredChannelState = 'open' | 'closed';
|
|
46
|
+
|
|
47
|
+
interface ChannelLifecycleEvent {
|
|
48
|
+
kind: 'desired-state' | 'legacy-policy-migrated' | 'invitation-declined';
|
|
49
|
+
serverId: string;
|
|
50
|
+
timestamp: string;
|
|
51
|
+
channelId?: string;
|
|
52
|
+
desired?: DesiredChannelState;
|
|
53
|
+
source?: string;
|
|
54
|
+
messageId?: string;
|
|
55
|
+
acknowledgment?: string;
|
|
56
|
+
}
|
|
40
57
|
|
|
41
58
|
function shallowEqualRecord(
|
|
42
59
|
a: Record<string, unknown> | undefined,
|
|
@@ -141,15 +158,47 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
141
158
|
},
|
|
142
159
|
{
|
|
143
160
|
name: 'channel_open',
|
|
144
|
-
description:
|
|
161
|
+
description:
|
|
162
|
+
'Open a channel to start receiving its ordinary ongoing traffic. The MCPL ' +
|
|
163
|
+
'integration performs its own subscribe/join/attach operation. Optionally request ' +
|
|
164
|
+
'history preceding the message that invited you into the channel.',
|
|
145
165
|
inputSchema: {
|
|
146
166
|
type: 'object' as const,
|
|
147
167
|
properties: {
|
|
148
168
|
channelId: { type: 'string', description: 'ID of the channel to open' },
|
|
169
|
+
serverId: { type: 'string', description: 'Owning MCPL server; required only when channelId is ambiguous.' },
|
|
170
|
+
backscroll: {
|
|
171
|
+
type: 'number',
|
|
172
|
+
description: 'Number of earlier messages to return while opening (0-500; default 0).',
|
|
173
|
+
},
|
|
174
|
+
beforeMessageId: {
|
|
175
|
+
type: 'string',
|
|
176
|
+
description: 'Anchor message from the closed-channel notice; it is excluded from backscroll.',
|
|
177
|
+
},
|
|
149
178
|
},
|
|
150
179
|
required: ['channelId'],
|
|
151
180
|
},
|
|
152
181
|
},
|
|
182
|
+
{
|
|
183
|
+
name: 'channel_decline',
|
|
184
|
+
description:
|
|
185
|
+
'Deliberately remain closed after being addressed in a closed channel. Optionally ' +
|
|
186
|
+
'post a public acknowledgment through the MCPL integration. Acknowledgment is ' +
|
|
187
|
+
'opt-in; omitting it declines silently.',
|
|
188
|
+
inputSchema: {
|
|
189
|
+
type: 'object' as const,
|
|
190
|
+
properties: {
|
|
191
|
+
channelId: { type: 'string', description: 'Channel from the invitation notice.' },
|
|
192
|
+
serverId: { type: 'string', description: 'Owning MCPL server from the invitation notice.' },
|
|
193
|
+
messageId: { type: 'string', description: 'Triggering message to acknowledge.' },
|
|
194
|
+
acknowledge: {
|
|
195
|
+
type: 'string',
|
|
196
|
+
description: 'Optional surface value such as 👀. Omit for a silent decline.',
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
required: ['channelId', 'messageId'],
|
|
200
|
+
},
|
|
201
|
+
},
|
|
153
202
|
{
|
|
154
203
|
name: 'channel_close',
|
|
155
204
|
description: 'Close a channel to stop receiving messages',
|
|
@@ -157,6 +206,7 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
157
206
|
type: 'object' as const,
|
|
158
207
|
properties: {
|
|
159
208
|
channelId: { type: 'string', description: 'ID of the channel to close' },
|
|
209
|
+
serverId: { type: 'string', description: 'Owning MCPL server; required only when channelId is ambiguous.' },
|
|
160
210
|
},
|
|
161
211
|
required: ['channelId'],
|
|
162
212
|
},
|
|
@@ -218,6 +268,8 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
218
268
|
// ============================================================================
|
|
219
269
|
|
|
220
270
|
interface ChannelRegistryOptions {
|
|
271
|
+
/** Chronicle store used for durable desired channel lifecycle state. */
|
|
272
|
+
store?: JsStore;
|
|
221
273
|
/** Callback to determine whether an incoming message should trigger inference. */
|
|
222
274
|
shouldTriggerInference?: (content: string, metadata: Record<string, unknown>) => boolean;
|
|
223
275
|
/**
|
|
@@ -285,6 +337,7 @@ export class ChannelRegistry {
|
|
|
285
337
|
}) => void;
|
|
286
338
|
private homeChannelResolver?: (agentName: string) => string | undefined;
|
|
287
339
|
private activeChannelResolver?: (agentName: string) => string | undefined;
|
|
340
|
+
private store?: JsStore;
|
|
288
341
|
|
|
289
342
|
/** Registered channels, keyed by `{serverId}:{channelId}`. */
|
|
290
343
|
private channels = new Map<string, ChannelEntry>();
|
|
@@ -303,12 +356,12 @@ export class ChannelRegistry {
|
|
|
303
356
|
* server keeps getting the same routing hints (e.g. Zulip topic). */
|
|
304
357
|
private typingMetadata = new Map<string, Record<string, unknown>>();
|
|
305
358
|
|
|
306
|
-
/**
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
private
|
|
359
|
+
/** Chronicle-projected desired lifecycle state, keyed by server + channel. */
|
|
360
|
+
private desiredStates = new Map<string, DesiredChannelState>();
|
|
361
|
+
|
|
362
|
+
/** One-time migration inputs from the retired recipe auto-open policy. */
|
|
363
|
+
private legacyPolicies = new Map<string, 'auto' | 'manual' | string[]>();
|
|
364
|
+
private migratedLegacyPolicies = new Set<string>();
|
|
312
365
|
|
|
313
366
|
constructor(
|
|
314
367
|
serverRegistry: McplServerRegistry,
|
|
@@ -333,14 +386,17 @@ export class ChannelRegistry {
|
|
|
333
386
|
this.onRouteFailure = options?.onRouteFailure;
|
|
334
387
|
this.homeChannelResolver = options?.homeChannelResolver;
|
|
335
388
|
this.activeChannelResolver = options?.activeChannelResolver;
|
|
389
|
+
this.store = options?.store;
|
|
390
|
+
this.initializeLifecycleStore();
|
|
336
391
|
}
|
|
337
392
|
|
|
338
393
|
/**
|
|
339
|
-
*
|
|
340
|
-
* when each MCPL server is registered.
|
|
394
|
+
* Supply a legacy recipe policy for one-time migration into Chronicle.
|
|
341
395
|
*/
|
|
342
396
|
setSubscriptionPolicy(serverId: string, policy: 'auto' | 'manual' | string[]): void {
|
|
343
|
-
|
|
397
|
+
// Backward-compatible recipe ingestion only. The policy is consumed once
|
|
398
|
+
// to seed Chronicle, then never applied to newly discovered channels.
|
|
399
|
+
this.legacyPolicies.set(serverId, policy);
|
|
344
400
|
}
|
|
345
401
|
|
|
346
402
|
// ==========================================================================
|
|
@@ -350,7 +406,7 @@ export class ChannelRegistry {
|
|
|
350
406
|
/**
|
|
351
407
|
* Handle `channels/register` from a server.
|
|
352
408
|
*
|
|
353
|
-
* Registers
|
|
409
|
+
* Registers descriptors and reconciles them to durable desired state.
|
|
354
410
|
*/
|
|
355
411
|
async handleRegister(
|
|
356
412
|
serverId: string,
|
|
@@ -369,13 +425,15 @@ export class ChannelRegistry {
|
|
|
369
425
|
registeredIds.push(channel.id);
|
|
370
426
|
}
|
|
371
427
|
|
|
372
|
-
// Respond before
|
|
428
|
+
// Respond before reconciliation — the server blocks on this response and
|
|
373
429
|
// can't process channels/open until it arrives.
|
|
374
430
|
const result: ChannelsRegisterResult = { registered: registeredIds };
|
|
375
431
|
responder?.respond(result);
|
|
376
432
|
|
|
377
|
-
//
|
|
378
|
-
|
|
433
|
+
// One-time migration from the retired recipe policy, then reconcile the
|
|
434
|
+
// server to Chronicle-backed desired state.
|
|
435
|
+
this.migrateLegacyPolicy(serverId, params.channels);
|
|
436
|
+
await this.reconcileChannels(serverId, params.channels);
|
|
379
437
|
|
|
380
438
|
this.emitTraceFn({
|
|
381
439
|
type: 'mcpl:channels-register',
|
|
@@ -388,7 +446,7 @@ export class ChannelRegistry {
|
|
|
388
446
|
/**
|
|
389
447
|
* Handle `channels/changed` notification from a server.
|
|
390
448
|
*
|
|
391
|
-
* Processes added (register +
|
|
449
|
+
* Processes added (register + reconcile), removed (delete + stop typing),
|
|
392
450
|
* and updated (replace descriptor) channels.
|
|
393
451
|
*/
|
|
394
452
|
async handleChanged(
|
|
@@ -415,7 +473,7 @@ export class ChannelRegistry {
|
|
|
415
473
|
}
|
|
416
474
|
}
|
|
417
475
|
|
|
418
|
-
// Process added channels (register +
|
|
476
|
+
// Process added channels (register + reconcile durable desired state)
|
|
419
477
|
if (params.added) {
|
|
420
478
|
for (const channel of params.added) {
|
|
421
479
|
const key = `${serverId}:${channel.id}`;
|
|
@@ -425,7 +483,7 @@ export class ChannelRegistry {
|
|
|
425
483
|
open: false,
|
|
426
484
|
});
|
|
427
485
|
}
|
|
428
|
-
await this.
|
|
486
|
+
await this.reconcileChannels(serverId, params.added);
|
|
429
487
|
}
|
|
430
488
|
|
|
431
489
|
this.emitTraceFn({
|
|
@@ -494,6 +552,12 @@ export class ChannelRegistry {
|
|
|
494
552
|
channelId: message.channelId,
|
|
495
553
|
label: channelLabel,
|
|
496
554
|
});
|
|
555
|
+
} else {
|
|
556
|
+
// A server sending channels/incoming is authoritative evidence that
|
|
557
|
+
// the transport is actually open. This repairs transient status only;
|
|
558
|
+
// durable desired state still changes exclusively through lifecycle
|
|
559
|
+
// operations.
|
|
560
|
+
this.channels.get(incomingKey)!.open = true;
|
|
497
561
|
}
|
|
498
562
|
|
|
499
563
|
// Determine whether to trigger inference
|
|
@@ -556,8 +620,9 @@ export class ChannelRegistry {
|
|
|
556
620
|
}
|
|
557
621
|
|
|
558
622
|
/**
|
|
559
|
-
* Ensure a channel is registered
|
|
560
|
-
*
|
|
623
|
+
* Ensure a channel is registered so it can serve as an outbound routing
|
|
624
|
+
* locus. A push event from a closed channel must never mutate lifecycle
|
|
625
|
+
* state: direct-address events are intentionally usable without subscribing.
|
|
561
626
|
*
|
|
562
627
|
* Mirrors the lazy-registration inside handleIncoming(), but for channels
|
|
563
628
|
* that only ever arrive as push/events rather than channels/incoming — the
|
|
@@ -567,15 +632,11 @@ export class ChannelRegistry {
|
|
|
567
632
|
* silently dropped even though the inbound message proves the channel is
|
|
568
633
|
* reachable (item-3 redux, DM sub-case). Idempotent: a later authoritative
|
|
569
634
|
* channels/register or channels/changed overwrites this descriptor with the
|
|
570
|
-
* richer one
|
|
635
|
+
* richer one.
|
|
571
636
|
*/
|
|
572
637
|
ensureChannelRegistered(serverId: string, channelId: string, label?: string): void {
|
|
573
638
|
const existing = this.findChannelEntry(channelId);
|
|
574
639
|
if (existing) {
|
|
575
|
-
if (!existing.open) {
|
|
576
|
-
existing.open = true;
|
|
577
|
-
this.emitTraceFn({ type: 'mcpl:channel-opened', serverId: existing.serverId, channelId });
|
|
578
|
-
}
|
|
579
640
|
return;
|
|
580
641
|
}
|
|
581
642
|
|
|
@@ -589,7 +650,7 @@ export class ChannelRegistry {
|
|
|
589
650
|
direction: 'bidirectional',
|
|
590
651
|
metadata: { lazyRegistered: true },
|
|
591
652
|
},
|
|
592
|
-
open:
|
|
653
|
+
open: false,
|
|
593
654
|
});
|
|
594
655
|
this.emitTraceFn({
|
|
595
656
|
type: 'mcpl:channel-lazy-registered',
|
|
@@ -713,6 +774,14 @@ export class ChannelRegistry {
|
|
|
713
774
|
return this.findChannelEntry(channelId)?.descriptor;
|
|
714
775
|
}
|
|
715
776
|
|
|
777
|
+
isChannelOpen(channelId: string): boolean {
|
|
778
|
+
return this.findChannelEntry(channelId)?.open === true;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
getDesiredState(serverId: string, channelId: string): DesiredChannelState | undefined {
|
|
782
|
+
return this.desiredStates.get(this.lifecycleKey(serverId, channelId));
|
|
783
|
+
}
|
|
784
|
+
|
|
716
785
|
/**
|
|
717
786
|
* Get all open channels.
|
|
718
787
|
*/
|
|
@@ -746,10 +815,23 @@ export class ChannelRegistry {
|
|
|
746
815
|
return this.handleToolList();
|
|
747
816
|
|
|
748
817
|
case 'channel_open':
|
|
749
|
-
return this.handleToolOpen(input as {
|
|
818
|
+
return this.handleToolOpen(input as {
|
|
819
|
+
channelId: string;
|
|
820
|
+
serverId?: string;
|
|
821
|
+
backscroll?: number;
|
|
822
|
+
beforeMessageId?: string;
|
|
823
|
+
});
|
|
750
824
|
|
|
751
825
|
case 'channel_close':
|
|
752
|
-
return this.handleToolClose(input as { channelId: string });
|
|
826
|
+
return this.handleToolClose(input as { channelId: string; serverId?: string });
|
|
827
|
+
|
|
828
|
+
case 'channel_decline':
|
|
829
|
+
return this.handleToolDecline(input as {
|
|
830
|
+
channelId: string;
|
|
831
|
+
serverId?: string;
|
|
832
|
+
messageId: string;
|
|
833
|
+
acknowledge?: string;
|
|
834
|
+
});
|
|
753
835
|
|
|
754
836
|
case 'channel_publish':
|
|
755
837
|
return this.handleToolPublish(input as { channelId: string; content: string });
|
|
@@ -839,7 +921,7 @@ export class ChannelRegistry {
|
|
|
839
921
|
this.defaultPublishThreadId = undefined;
|
|
840
922
|
}
|
|
841
923
|
}
|
|
842
|
-
|
|
924
|
+
// Desired state and migration markers deliberately survive disconnects.
|
|
843
925
|
}
|
|
844
926
|
|
|
845
927
|
/**
|
|
@@ -862,65 +944,153 @@ export class ChannelRegistry {
|
|
|
862
944
|
}
|
|
863
945
|
|
|
864
946
|
// ==========================================================================
|
|
865
|
-
// Private:
|
|
947
|
+
// Private: Durable desired state and reconciliation
|
|
866
948
|
// ==========================================================================
|
|
867
949
|
|
|
868
|
-
private
|
|
950
|
+
private lifecycleKey(serverId: string, channelId: string): string {
|
|
951
|
+
return `${serverId}\u0000${channelId}`;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
private initializeLifecycleStore(): void {
|
|
955
|
+
if (!this.store) return;
|
|
956
|
+
|
|
957
|
+
try {
|
|
958
|
+
this.store.registerState({ id: CHANNEL_LIFECYCLE_LOG_ID, strategy: 'append_log' });
|
|
959
|
+
} catch (error) {
|
|
960
|
+
if (!(error instanceof Error) || !error.message.includes('State already exists')) {
|
|
961
|
+
throw error;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const raw = this.store.getStateJson(CHANNEL_LIFECYCLE_LOG_ID);
|
|
966
|
+
if (!Array.isArray(raw)) return;
|
|
967
|
+
|
|
968
|
+
for (const item of raw) {
|
|
969
|
+
if (!item || typeof item !== 'object') continue;
|
|
970
|
+
const event = item as Partial<ChannelLifecycleEvent>;
|
|
971
|
+
if (typeof event.serverId !== 'string') continue;
|
|
972
|
+
if (
|
|
973
|
+
event.kind === 'desired-state' &&
|
|
974
|
+
typeof event.channelId === 'string' &&
|
|
975
|
+
(event.desired === 'open' || event.desired === 'closed')
|
|
976
|
+
) {
|
|
977
|
+
this.desiredStates.set(
|
|
978
|
+
this.lifecycleKey(event.serverId, event.channelId),
|
|
979
|
+
event.desired,
|
|
980
|
+
);
|
|
981
|
+
} else if (event.kind === 'legacy-policy-migrated') {
|
|
982
|
+
this.migratedLegacyPolicies.add(event.serverId);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
private appendLifecycleEvent(event: ChannelLifecycleEvent): void {
|
|
988
|
+
this.store?.appendToStateJson(CHANNEL_LIFECYCLE_LOG_ID, event);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
private setDesiredState(
|
|
992
|
+
serverId: string,
|
|
993
|
+
channelId: string,
|
|
994
|
+
desired: DesiredChannelState,
|
|
995
|
+
source: string,
|
|
996
|
+
): void {
|
|
997
|
+
const key = this.lifecycleKey(serverId, channelId);
|
|
998
|
+
if (this.desiredStates.get(key) === desired) return;
|
|
999
|
+
this.desiredStates.set(key, desired);
|
|
1000
|
+
this.appendLifecycleEvent({
|
|
1001
|
+
kind: 'desired-state',
|
|
1002
|
+
serverId,
|
|
1003
|
+
channelId,
|
|
1004
|
+
desired,
|
|
1005
|
+
source,
|
|
1006
|
+
timestamp: new Date().toISOString(),
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Consume the old recipe policy exactly once. It seeds Chronicle for
|
|
1012
|
+
* existing deployments, but is not an ongoing admission policy: channels
|
|
1013
|
+
* discovered later use their server bootstrap preference, otherwise closed.
|
|
1014
|
+
*/
|
|
1015
|
+
private migrateLegacyPolicy(serverId: string, channels: ChannelDescriptor[]): void {
|
|
1016
|
+
if (this.migratedLegacyPolicies.has(serverId)) return;
|
|
1017
|
+
|
|
1018
|
+
const policy = this.legacyPolicies.get(serverId) ?? 'manual';
|
|
1019
|
+
const allowList = Array.isArray(policy) ? new Set(policy) : undefined;
|
|
1020
|
+
for (const channel of channels) {
|
|
1021
|
+
if (this.getDesiredState(serverId, channel.id)) continue;
|
|
1022
|
+
const desired: DesiredChannelState = channel.initiallyOpen === true ||
|
|
1023
|
+
policy === 'auto' || allowList?.has(channel.id)
|
|
1024
|
+
? 'open'
|
|
1025
|
+
: 'closed';
|
|
1026
|
+
this.setDesiredState(serverId, channel.id, desired, 'legacy-recipe-migration');
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
this.migratedLegacyPolicies.add(serverId);
|
|
1030
|
+
this.appendLifecycleEvent({
|
|
1031
|
+
kind: 'legacy-policy-migrated',
|
|
1032
|
+
serverId,
|
|
1033
|
+
source: Array.isArray(policy) ? 'allow-list' : policy,
|
|
1034
|
+
timestamp: new Date().toISOString(),
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
private ensureInitialDesiredState(serverId: string, channel: ChannelDescriptor): void {
|
|
1039
|
+
if (this.getDesiredState(serverId, channel.id)) return;
|
|
1040
|
+
this.setDesiredState(
|
|
1041
|
+
serverId,
|
|
1042
|
+
channel.id,
|
|
1043
|
+
channel.initiallyOpen === true ? 'open' : 'closed',
|
|
1044
|
+
channel.initiallyOpen === true ? 'server-bootstrap' : 'default-closed',
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
private async reconcileChannels(
|
|
869
1049
|
serverId: string,
|
|
870
1050
|
channels: ChannelDescriptor[],
|
|
871
1051
|
): Promise<void> {
|
|
872
1052
|
const server = this.serverRegistry.getServer(serverId);
|
|
873
1053
|
if (!server) return;
|
|
874
1054
|
|
|
875
|
-
const policy = this.subscriptionPolicies.get(serverId) ?? 'auto';
|
|
876
|
-
if (policy === 'manual') {
|
|
877
|
-
this.emitTraceFn({
|
|
878
|
-
type: 'mcpl:channels-auto-open-skipped',
|
|
879
|
-
serverId,
|
|
880
|
-
reason: 'manual',
|
|
881
|
-
count: channels.length,
|
|
882
|
-
});
|
|
883
|
-
return;
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
const allowList = Array.isArray(policy) ? new Set(policy) : null;
|
|
887
|
-
let opened = 0;
|
|
888
|
-
|
|
889
1055
|
for (const channel of channels) {
|
|
890
|
-
|
|
891
|
-
|
|
1056
|
+
this.ensureInitialDesiredState(serverId, channel);
|
|
892
1057
|
const key = `${serverId}:${channel.id}`;
|
|
1058
|
+
const desired = this.getDesiredState(serverId, channel.id);
|
|
1059
|
+
const entry = this.channels.get(key);
|
|
1060
|
+
if (desired !== 'open') {
|
|
1061
|
+
try {
|
|
1062
|
+
await server.sendChannelsClose({ channelId: channel.id });
|
|
1063
|
+
if (entry) entry.open = false;
|
|
1064
|
+
} catch (err) {
|
|
1065
|
+
this.emitTraceFn({
|
|
1066
|
+
type: 'mcpl:channel-reconcile-failed',
|
|
1067
|
+
serverId,
|
|
1068
|
+
channelId: channel.id,
|
|
1069
|
+
desired,
|
|
1070
|
+
error: (err as Error).message,
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
continue;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
893
1076
|
try {
|
|
894
1077
|
await server.sendChannelsOpen({
|
|
1078
|
+
channelId: channel.id,
|
|
895
1079
|
type: channel.type,
|
|
896
1080
|
address: channel.address,
|
|
897
1081
|
});
|
|
898
|
-
|
|
899
|
-
if (entry) {
|
|
900
|
-
entry.open = true;
|
|
901
|
-
}
|
|
902
|
-
opened++;
|
|
1082
|
+
if (entry) entry.open = true;
|
|
903
1083
|
} catch (err) {
|
|
1084
|
+
if (entry) entry.open = false;
|
|
904
1085
|
this.emitTraceFn({
|
|
905
|
-
type: 'mcpl:channel-
|
|
1086
|
+
type: 'mcpl:channel-reconcile-failed',
|
|
906
1087
|
serverId,
|
|
907
1088
|
channelId: channel.id,
|
|
1089
|
+
desired,
|
|
908
1090
|
error: (err as Error).message,
|
|
909
1091
|
});
|
|
910
1092
|
}
|
|
911
1093
|
}
|
|
912
|
-
|
|
913
|
-
// Surface the common typo failure mode: allow-list set but nothing
|
|
914
|
-
// matched. Without this trace a one-char typo in a channel id looks
|
|
915
|
-
// identical to "MCPL didn't register the channel".
|
|
916
|
-
if (allowList && opened === 0 && channels.length > 0) {
|
|
917
|
-
this.emitTraceFn({
|
|
918
|
-
type: 'mcpl:channels-allow-list-empty',
|
|
919
|
-
serverId,
|
|
920
|
-
allowList: [...allowList],
|
|
921
|
-
registered: channels.map(c => c.id),
|
|
922
|
-
});
|
|
923
|
-
}
|
|
924
1094
|
}
|
|
925
1095
|
|
|
926
1096
|
// ==========================================================================
|
|
@@ -963,6 +1133,28 @@ export class ChannelRegistry {
|
|
|
963
1133
|
return undefined;
|
|
964
1134
|
}
|
|
965
1135
|
|
|
1136
|
+
private resolveToolChannelEntry(
|
|
1137
|
+
channelId: string,
|
|
1138
|
+
serverId?: string,
|
|
1139
|
+
): { entry?: ChannelEntry; error?: string } {
|
|
1140
|
+
const matches = [...this.channels.values()].filter(
|
|
1141
|
+
(entry) => entry.descriptor.id === channelId && (!serverId || entry.serverId === serverId),
|
|
1142
|
+
);
|
|
1143
|
+
if (matches.length === 0) {
|
|
1144
|
+
return {
|
|
1145
|
+
error: serverId
|
|
1146
|
+
? `Channel not found: ${channelId} on server ${serverId}`
|
|
1147
|
+
: `Channel not found: ${channelId}`,
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
if (matches.length > 1) {
|
|
1151
|
+
return {
|
|
1152
|
+
error: `Channel id is ambiguous across MCPL servers: ${channelId}. Include serverId.`,
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
return { entry: matches[0] };
|
|
1156
|
+
}
|
|
1157
|
+
|
|
966
1158
|
/**
|
|
967
1159
|
* Find the composite key for a channel by its channelId.
|
|
968
1160
|
*/
|
|
@@ -986,6 +1178,7 @@ export class ChannelRegistry {
|
|
|
986
1178
|
label: string;
|
|
987
1179
|
direction: string;
|
|
988
1180
|
open: boolean;
|
|
1181
|
+
desired: DesiredChannelState | 'unknown';
|
|
989
1182
|
serverId: string;
|
|
990
1183
|
}> = [];
|
|
991
1184
|
|
|
@@ -996,6 +1189,7 @@ export class ChannelRegistry {
|
|
|
996
1189
|
label: entry.descriptor.label,
|
|
997
1190
|
direction: entry.descriptor.direction,
|
|
998
1191
|
open: entry.open,
|
|
1192
|
+
desired: this.getDesiredState(entry.serverId, entry.descriptor.id) ?? 'unknown',
|
|
999
1193
|
serverId: entry.serverId,
|
|
1000
1194
|
});
|
|
1001
1195
|
}
|
|
@@ -1006,23 +1200,32 @@ export class ChannelRegistry {
|
|
|
1006
1200
|
};
|
|
1007
1201
|
}
|
|
1008
1202
|
|
|
1009
|
-
private async handleToolOpen(input: {
|
|
1010
|
-
|
|
1203
|
+
private async handleToolOpen(input: {
|
|
1204
|
+
channelId: string;
|
|
1205
|
+
serverId?: string;
|
|
1206
|
+
backscroll?: number;
|
|
1207
|
+
beforeMessageId?: string;
|
|
1208
|
+
}): Promise<ToolResult> {
|
|
1209
|
+
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
|
|
1210
|
+
const entry = resolved.entry;
|
|
1011
1211
|
if (!entry) {
|
|
1012
1212
|
return {
|
|
1013
1213
|
success: false,
|
|
1014
|
-
error:
|
|
1214
|
+
error: resolved.error,
|
|
1015
1215
|
isError: true,
|
|
1016
1216
|
};
|
|
1017
1217
|
}
|
|
1018
1218
|
|
|
1019
|
-
|
|
1219
|
+
const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open';
|
|
1220
|
+
if (entry.open && alreadyDesiredOpen && !input.backscroll) {
|
|
1020
1221
|
return {
|
|
1021
1222
|
success: true,
|
|
1022
1223
|
data: { channelId: input.channelId, status: 'already open' },
|
|
1023
1224
|
};
|
|
1024
1225
|
}
|
|
1025
1226
|
|
|
1227
|
+
this.setDesiredState(entry.serverId, input.channelId, 'open', 'agent-tool');
|
|
1228
|
+
|
|
1026
1229
|
const server = this.serverRegistry.getServer(entry.serverId);
|
|
1027
1230
|
if (!server) {
|
|
1028
1231
|
return {
|
|
@@ -1033,14 +1236,27 @@ export class ChannelRegistry {
|
|
|
1033
1236
|
}
|
|
1034
1237
|
|
|
1035
1238
|
try {
|
|
1036
|
-
|
|
1239
|
+
const history: ChannelHistoryRequest | undefined = input.backscroll
|
|
1240
|
+
? {
|
|
1241
|
+
limit: Math.max(0, Math.min(500, Math.floor(input.backscroll))),
|
|
1242
|
+
...(input.beforeMessageId ? { beforeMessageId: input.beforeMessageId } : {}),
|
|
1243
|
+
}
|
|
1244
|
+
: undefined;
|
|
1245
|
+
const result: ChannelsOpenResult = await server.sendChannelsOpen({
|
|
1246
|
+
channelId: entry.descriptor.id,
|
|
1037
1247
|
type: entry.descriptor.type,
|
|
1038
1248
|
address: entry.descriptor.address,
|
|
1249
|
+
...(history ? { history } : {}),
|
|
1039
1250
|
});
|
|
1040
1251
|
entry.open = true;
|
|
1041
1252
|
return {
|
|
1042
1253
|
success: true,
|
|
1043
|
-
data: {
|
|
1254
|
+
data: {
|
|
1255
|
+
channelId: input.channelId,
|
|
1256
|
+
status: alreadyDesiredOpen ? 'reconciled' : 'opened',
|
|
1257
|
+
...(result.history ? { history: result.history } : {}),
|
|
1258
|
+
...(result.historyTruncated ? { historyTruncated: true } : {}),
|
|
1259
|
+
},
|
|
1044
1260
|
};
|
|
1045
1261
|
} catch (err) {
|
|
1046
1262
|
return {
|
|
@@ -1051,23 +1267,28 @@ export class ChannelRegistry {
|
|
|
1051
1267
|
}
|
|
1052
1268
|
}
|
|
1053
1269
|
|
|
1054
|
-
private async handleToolClose(input: { channelId: string }): Promise<ToolResult> {
|
|
1055
|
-
const
|
|
1270
|
+
private async handleToolClose(input: { channelId: string; serverId?: string }): Promise<ToolResult> {
|
|
1271
|
+
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
|
|
1272
|
+
const entry = resolved.entry;
|
|
1056
1273
|
if (!entry) {
|
|
1057
1274
|
return {
|
|
1058
1275
|
success: false,
|
|
1059
|
-
error:
|
|
1276
|
+
error: resolved.error,
|
|
1060
1277
|
isError: true,
|
|
1061
1278
|
};
|
|
1062
1279
|
}
|
|
1063
1280
|
|
|
1064
|
-
|
|
1281
|
+
const alreadyDesiredClosed = this.getDesiredState(entry.serverId, input.channelId) === 'closed';
|
|
1282
|
+
if (!entry.open && alreadyDesiredClosed) {
|
|
1065
1283
|
return {
|
|
1066
1284
|
success: true,
|
|
1067
1285
|
data: { channelId: input.channelId, status: 'already closed' },
|
|
1068
1286
|
};
|
|
1069
1287
|
}
|
|
1070
1288
|
|
|
1289
|
+
this.setDesiredState(entry.serverId, input.channelId, 'closed', 'agent-tool');
|
|
1290
|
+
this.stopTyping(input.channelId);
|
|
1291
|
+
|
|
1071
1292
|
const server = this.serverRegistry.getServer(entry.serverId);
|
|
1072
1293
|
if (!server) {
|
|
1073
1294
|
return {
|
|
@@ -1080,7 +1301,6 @@ export class ChannelRegistry {
|
|
|
1080
1301
|
try {
|
|
1081
1302
|
await server.sendChannelsClose({ channelId: input.channelId });
|
|
1082
1303
|
entry.open = false;
|
|
1083
|
-
this.stopTyping(input.channelId);
|
|
1084
1304
|
return {
|
|
1085
1305
|
success: true,
|
|
1086
1306
|
data: { channelId: input.channelId, status: 'closed' },
|
|
@@ -1094,6 +1314,76 @@ export class ChannelRegistry {
|
|
|
1094
1314
|
}
|
|
1095
1315
|
}
|
|
1096
1316
|
|
|
1317
|
+
private async handleToolDecline(input: {
|
|
1318
|
+
channelId: string;
|
|
1319
|
+
serverId?: string;
|
|
1320
|
+
messageId: string;
|
|
1321
|
+
acknowledge?: string;
|
|
1322
|
+
}): Promise<ToolResult> {
|
|
1323
|
+
const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
|
|
1324
|
+
const entry = resolved.entry;
|
|
1325
|
+
if (!entry) {
|
|
1326
|
+
return { success: false, error: resolved.error, isError: true };
|
|
1327
|
+
}
|
|
1328
|
+
if (entry.open || this.getDesiredState(entry.serverId, input.channelId) === 'open') {
|
|
1329
|
+
return {
|
|
1330
|
+
success: false,
|
|
1331
|
+
error: `Channel is already open: ${input.channelId}. Use channel_close to leave it.`,
|
|
1332
|
+
isError: true,
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// The lifecycle decision is authoritative even if the optional public
|
|
1337
|
+
// acknowledgment cannot be rendered by the surface.
|
|
1338
|
+
this.setDesiredState(entry.serverId, input.channelId, 'closed', 'invitation-declined');
|
|
1339
|
+
|
|
1340
|
+
let acknowledged = false;
|
|
1341
|
+
let representation: string | undefined;
|
|
1342
|
+
let acknowledgmentError: string | undefined;
|
|
1343
|
+
if (input.acknowledge) {
|
|
1344
|
+
const server = this.serverRegistry.getServer(entry.serverId);
|
|
1345
|
+
if (!server) {
|
|
1346
|
+
acknowledgmentError = `Server not found: ${entry.serverId}`;
|
|
1347
|
+
} else {
|
|
1348
|
+
try {
|
|
1349
|
+
const result = await server.sendChannelsAcknowledge({
|
|
1350
|
+
channelId: input.channelId,
|
|
1351
|
+
messageId: input.messageId,
|
|
1352
|
+
intent: 'seen-not-opening',
|
|
1353
|
+
value: input.acknowledge,
|
|
1354
|
+
});
|
|
1355
|
+
acknowledged = result.acknowledged;
|
|
1356
|
+
representation = result.representation;
|
|
1357
|
+
if (!acknowledged) {
|
|
1358
|
+
acknowledgmentError = result.reason ??
|
|
1359
|
+
'The channel integration could not post the acknowledgment.';
|
|
1360
|
+
}
|
|
1361
|
+
} catch (error) {
|
|
1362
|
+
acknowledgmentError = (error as Error).message;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
this.appendLifecycleEvent({
|
|
1368
|
+
kind: 'invitation-declined',
|
|
1369
|
+
serverId: entry.serverId,
|
|
1370
|
+
channelId: input.channelId,
|
|
1371
|
+
messageId: input.messageId,
|
|
1372
|
+
acknowledgment: representation ?? input.acknowledge,
|
|
1373
|
+
timestamp: new Date().toISOString(),
|
|
1374
|
+
});
|
|
1375
|
+
return {
|
|
1376
|
+
success: true,
|
|
1377
|
+
data: {
|
|
1378
|
+
channelId: input.channelId,
|
|
1379
|
+
status: 'remained closed',
|
|
1380
|
+
acknowledged,
|
|
1381
|
+
...(representation ? { representation } : {}),
|
|
1382
|
+
...(acknowledgmentError ? { acknowledgmentError } : {}),
|
|
1383
|
+
},
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1097
1387
|
/**
|
|
1098
1388
|
* Handle the synthesized `think` tool — a private reasoning scratchpad. It
|
|
1099
1389
|
* sends nothing, and (unlike before) does NOT silence the turn: trailing
|
|
@@ -1123,6 +1413,12 @@ export class ChannelRegistry {
|
|
|
1123
1413
|
private handleToolSkipReply(input: { reason?: string }): ToolResult {
|
|
1124
1414
|
return {
|
|
1125
1415
|
success: true,
|
|
1416
|
+
// The note says "ended the turn" — make it TRUE. Without endTurn the
|
|
1417
|
+
// framework resumes the stream after the tool result, and a model with
|
|
1418
|
+
// nothing to say (told its turn already ended) just calls skip_reply
|
|
1419
|
+
// again: observed as a 40+ round skip_reply loop on Fable 5, burning a
|
|
1420
|
+
// round-trip + ~70 tokens per iteration until something kills the turn.
|
|
1421
|
+
endTurn: true,
|
|
1126
1422
|
data: {
|
|
1127
1423
|
skipped: true,
|
|
1128
1424
|
note: 'Ended the turn without replying — nothing was sent to any channel.',
|