@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.
Files changed (69) hide show
  1. package/dist/src/agent.d.ts +18 -1
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +201 -2
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +15 -1
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +423 -86
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts.map +1 -1
  10. package/dist/src/gate/event-gate.js +27 -4
  11. package/dist/src/gate/event-gate.js.map +1 -1
  12. package/dist/src/mcpl/channel-registry.d.ts +34 -15
  13. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  14. package/dist/src/mcpl/channel-registry.js +299 -67
  15. package/dist/src/mcpl/channel-registry.js.map +1 -1
  16. package/dist/src/mcpl/push-handler.d.ts +1 -0
  17. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  18. package/dist/src/mcpl/push-handler.js +1 -0
  19. package/dist/src/mcpl/push-handler.js.map +1 -1
  20. package/dist/src/mcpl/server-connection.d.ts +3 -1
  21. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  22. package/dist/src/mcpl/server-connection.js +4 -0
  23. package/dist/src/mcpl/server-connection.js.map +1 -1
  24. package/dist/src/mcpl/types.d.ts +43 -12
  25. package/dist/src/mcpl/types.d.ts.map +1 -1
  26. package/dist/src/mcpl/types.js +1 -0
  27. package/dist/src/mcpl/types.js.map +1 -1
  28. package/dist/src/types/agent.d.ts +15 -0
  29. package/dist/src/types/agent.d.ts.map +1 -1
  30. package/dist/src/types/events.d.ts +1 -0
  31. package/dist/src/types/events.d.ts.map +1 -1
  32. package/dist/src/types/trace.d.ts +2 -0
  33. package/dist/src/types/trace.d.ts.map +1 -1
  34. package/dist/test/agent-runtime-settings.test.d.ts +2 -0
  35. package/dist/test/agent-runtime-settings.test.d.ts.map +1 -0
  36. package/dist/test/agent-runtime-settings.test.js +120 -0
  37. package/dist/test/agent-runtime-settings.test.js.map +1 -0
  38. package/dist/test/channel-registry-routing.test.js +5 -4
  39. package/dist/test/channel-registry-routing.test.js.map +1 -1
  40. package/dist/test/conversation-fork-injection-scoping.test.js +12 -1
  41. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
  42. package/dist/test/event-gate.test.js +21 -1
  43. package/dist/test/event-gate.test.js.map +1 -1
  44. package/dist/test/helpers/mock-membrane.d.ts +13 -1
  45. package/dist/test/helpers/mock-membrane.d.ts.map +1 -1
  46. package/dist/test/helpers/mock-membrane.js +8 -1
  47. package/dist/test/helpers/mock-membrane.js.map +1 -1
  48. package/dist/test/maintenance-timer.test.js +3 -1
  49. package/dist/test/maintenance-timer.test.js.map +1 -1
  50. package/dist/test/mcpl-gate-roundtrip.test.js +138 -6
  51. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
  52. package/dist/test/present-while-acting.test.d.ts +18 -0
  53. package/dist/test/present-while-acting.test.d.ts.map +1 -0
  54. package/dist/test/present-while-acting.test.js +267 -0
  55. package/dist/test/present-while-acting.test.js.map +1 -0
  56. package/dist/test/trunk-channel-routing.test.js +38 -0
  57. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  58. package/dist/tsconfig.tsbuildinfo +1 -1
  59. package/package.json +3 -3
  60. package/src/agent.ts +222 -3
  61. package/src/framework.ts +468 -92
  62. package/src/gate/event-gate.ts +38 -4
  63. package/src/mcpl/channel-registry.ts +372 -76
  64. package/src/mcpl/push-handler.ts +2 -0
  65. package/src/mcpl/server-connection.ts +10 -0
  66. package/src/mcpl/types.ts +49 -12
  67. package/src/types/agent.ts +18 -0
  68. package/src/types/events.ts +1 -1
  69. 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
- * - Auto-open channels on registration
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: 'Open a channel to start receiving messages',
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
- * Per-server channel auto-open policy. Populated by the framework from
308
- * each McplServerConfig at connect time; default ('auto') applies when
309
- * unset.
310
- */
311
- private subscriptionPolicies = new Map<string, 'auto' | 'manual' | string[]>();
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
- * Set the channel auto-open policy for a server. Called by the framework
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
- this.subscriptionPolicies.set(serverId, policy);
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 each channel descriptor in the map and auto-opens them.
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 auto-opening — the server blocks on this response and
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
- // Auto-open all registered channels
378
- await this.autoOpenChannels(serverId, params.channels);
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 + auto-open), removed (delete + stop typing),
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 + auto-open)
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.autoOpenChannels(serverId, params.added);
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 AND open so it can serve as an outbound
560
- * routing locus, lazy-registering it if unseen and opening it if closed.
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; re-opening an already-open channel is a no-op.
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: true,
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 { channelId: string });
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
- this.subscriptionPolicies.delete(serverId);
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: Auto-open channels
947
+ // Private: Durable desired state and reconciliation
866
948
  // ==========================================================================
867
949
 
868
- private async autoOpenChannels(
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
- if (allowList && !allowList.has(channel.id)) continue;
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
- const entry = this.channels.get(key);
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-open-failed',
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: { channelId: string }): Promise<ToolResult> {
1010
- const entry = this.findChannelEntry(input.channelId);
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: `Channel not found: ${input.channelId}`,
1214
+ error: resolved.error,
1015
1215
  isError: true,
1016
1216
  };
1017
1217
  }
1018
1218
 
1019
- if (entry.open) {
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
- await server.sendChannelsOpen({
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: { channelId: input.channelId, status: 'opened' },
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 entry = this.findChannelEntry(input.channelId);
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: `Channel not found: ${input.channelId}`,
1276
+ error: resolved.error,
1060
1277
  isError: true,
1061
1278
  };
1062
1279
  }
1063
1280
 
1064
- if (!entry.open) {
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.',