@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
@@ -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 messages',
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
- * Per-server channel auto-open policy. Populated by the framework from
177
- * each McplServerConfig at connect time; default ('auto') applies when
178
- * unset.
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
- * Set the channel auto-open policy for a server. Called by the framework
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
- this.subscriptionPolicies.set(serverId, policy);
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 each channel descriptor in the map and auto-opens them.
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 auto-opening — the server blocks on this response and
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
- // Auto-open all registered channels
223
- await this.autoOpenChannels(serverId, params.channels);
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 + auto-open), removed (delete + stop typing),
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 + auto-open)
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.autoOpenChannels(serverId, params.added);
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 AND open so it can serve as an outbound
378
- * routing locus, lazy-registering it if unseen and opening it if closed.
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; re-opening an already-open channel is a no-op.
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: true,
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
- this.subscriptionPolicies.delete(serverId);
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: Auto-open channels
697
+ // Private: Durable desired state and reconciliation
649
698
  // ==========================================================================
650
- async autoOpenChannels(serverId, channels) {
651
- const server = this.serverRegistry.getServer(serverId);
652
- if (!server)
699
+ lifecycleKey(serverId, channelId) {
700
+ return `${serverId}\u0000${channelId}`;
701
+ }
702
+ initializeLifecycleStore() {
703
+ if (!this.store)
653
704
  return;
654
- const policy = this.subscriptionPolicies.get(serverId) ?? 'auto';
655
- if (policy === 'manual') {
656
- this.emitTraceFn({
657
- type: 'mcpl:channels-auto-open-skipped',
658
- serverId,
659
- reason: 'manual',
660
- count: channels.length,
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
- const allowList = Array.isArray(policy) ? new Set(policy) : null;
665
- let opened = 0;
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 (allowList && !allowList.has(channel.id))
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
- const entry = this.channels.get(key);
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-open-failed',
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 entry = this.findChannelEntry(input.channelId);
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: `Channel not found: ${input.channelId}`,
918
+ error: resolved.error,
774
919
  isError: true,
775
920
  };
776
921
  }
777
- if (entry.open) {
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
- await server.sendChannelsOpen({
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: { channelId: input.channelId, status: 'opened' },
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 entry = this.findChannelEntry(input.channelId);
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: `Channel not found: ${input.channelId}`,
976
+ error: resolved.error,
816
977
  isError: true,
817
978
  };
818
979
  }
819
- if (!entry.open) {
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.',