@animalabs/agent-framework 0.6.5 → 0.6.7

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 (155) hide show
  1. package/README.md +78 -0
  2. package/dist/src/agent.d.ts +56 -2
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +372 -57
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server.js +0 -0
  7. package/dist/src/api/server.d.ts.map +1 -1
  8. package/dist/src/api/server.js +7 -2
  9. package/dist/src/api/server.js.map +1 -1
  10. package/dist/src/canonical-json.d.ts +2 -0
  11. package/dist/src/canonical-json.d.ts.map +1 -0
  12. package/dist/src/canonical-json.js +23 -0
  13. package/dist/src/canonical-json.js.map +1 -0
  14. package/dist/src/framework.d.ts +160 -2
  15. package/dist/src/framework.d.ts.map +1 -1
  16. package/dist/src/framework.js +3098 -167
  17. package/dist/src/framework.js.map +1 -1
  18. package/dist/src/gate/event-gate.d.ts.map +1 -1
  19. package/dist/src/gate/event-gate.js +34 -4
  20. package/dist/src/gate/event-gate.js.map +1 -1
  21. package/dist/src/index.d.ts +4 -0
  22. package/dist/src/index.d.ts.map +1 -1
  23. package/dist/src/index.js +3 -0
  24. package/dist/src/index.js.map +1 -1
  25. package/dist/src/mcpl/channel-registry.d.ts +36 -15
  26. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  27. package/dist/src/mcpl/channel-registry.js +322 -76
  28. package/dist/src/mcpl/channel-registry.js.map +1 -1
  29. package/dist/src/mcpl/push-handler.d.ts +1 -0
  30. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  31. package/dist/src/mcpl/push-handler.js +1 -0
  32. package/dist/src/mcpl/push-handler.js.map +1 -1
  33. package/dist/src/mcpl/server-connection.d.ts +39 -4
  34. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  35. package/dist/src/mcpl/server-connection.js +146 -28
  36. package/dist/src/mcpl/server-connection.js.map +1 -1
  37. package/dist/src/mcpl/types.d.ts +43 -12
  38. package/dist/src/mcpl/types.d.ts.map +1 -1
  39. package/dist/src/mcpl/types.js +1 -0
  40. package/dist/src/mcpl/types.js.map +1 -1
  41. package/dist/src/modules/workspace/index.d.ts +12 -1
  42. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  43. package/dist/src/modules/workspace/index.js +661 -2
  44. package/dist/src/modules/workspace/index.js.map +1 -1
  45. package/dist/src/modules/workspace/types.d.ts +4 -0
  46. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  47. package/dist/src/modules/workspace/types.js.map +1 -1
  48. package/dist/src/recovery/discord-awareness-outbox.d.ts +99 -0
  49. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -0
  50. package/dist/src/recovery/discord-awareness-outbox.js +334 -0
  51. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -0
  52. package/dist/src/recovery/offline-branch.d.ts +45 -0
  53. package/dist/src/recovery/offline-branch.d.ts.map +1 -0
  54. package/dist/src/recovery/offline-branch.js +362 -0
  55. package/dist/src/recovery/offline-branch.js.map +1 -0
  56. package/dist/src/recovery/recover-cli.d.ts +3 -0
  57. package/dist/src/recovery/recover-cli.d.ts.map +1 -0
  58. package/dist/src/recovery/recover-cli.js +129 -0
  59. package/dist/src/recovery/recover-cli.js.map +1 -0
  60. package/dist/src/types/agent.d.ts +37 -0
  61. package/dist/src/types/agent.d.ts.map +1 -1
  62. package/dist/src/types/events.d.ts +1 -0
  63. package/dist/src/types/events.d.ts.map +1 -1
  64. package/dist/src/types/framework.d.ts +14 -0
  65. package/dist/src/types/framework.d.ts.map +1 -1
  66. package/dist/src/types/module.d.ts +31 -0
  67. package/dist/src/types/module.d.ts.map +1 -1
  68. package/dist/src/types/trace.d.ts +4 -0
  69. package/dist/src/types/trace.d.ts.map +1 -1
  70. package/dist/test/agent-runtime-settings.test.d.ts +2 -0
  71. package/dist/test/agent-runtime-settings.test.d.ts.map +1 -0
  72. package/dist/test/agent-runtime-settings.test.js +227 -0
  73. package/dist/test/agent-runtime-settings.test.js.map +1 -0
  74. package/dist/test/busy-agent-scheduler.test.d.ts +2 -0
  75. package/dist/test/busy-agent-scheduler.test.d.ts.map +1 -0
  76. package/dist/test/busy-agent-scheduler.test.js +62 -0
  77. package/dist/test/busy-agent-scheduler.test.js.map +1 -0
  78. package/dist/test/channel-lifecycle-provenance.test.d.ts +2 -0
  79. package/dist/test/channel-lifecycle-provenance.test.d.ts.map +1 -0
  80. package/dist/test/channel-lifecycle-provenance.test.js +47 -0
  81. package/dist/test/channel-lifecycle-provenance.test.js.map +1 -0
  82. package/dist/test/channel-registry-routing.test.js +5 -4
  83. package/dist/test/channel-registry-routing.test.js.map +1 -1
  84. package/dist/test/conversation-fork-injection-scoping.test.js +12 -1
  85. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
  86. package/dist/test/discord-awareness-outbox.test.d.ts +2 -0
  87. package/dist/test/discord-awareness-outbox.test.d.ts.map +1 -0
  88. package/dist/test/discord-awareness-outbox.test.js +248 -0
  89. package/dist/test/discord-awareness-outbox.test.js.map +1 -0
  90. package/dist/test/event-gate.test.js +21 -1
  91. package/dist/test/event-gate.test.js.map +1 -1
  92. package/dist/test/helpers/mock-membrane.d.ts +13 -1
  93. package/dist/test/helpers/mock-membrane.d.ts.map +1 -1
  94. package/dist/test/helpers/mock-membrane.js +9 -2
  95. package/dist/test/helpers/mock-membrane.js.map +1 -1
  96. package/dist/test/maintenance-timer.test.js +103 -1
  97. package/dist/test/maintenance-timer.test.js.map +1 -1
  98. package/dist/test/mcpl-awareness-barrier.test.d.ts +2 -0
  99. package/dist/test/mcpl-awareness-barrier.test.d.ts.map +1 -0
  100. package/dist/test/mcpl-awareness-barrier.test.js +845 -0
  101. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -0
  102. package/dist/test/mcpl-gate-roundtrip.test.js +138 -6
  103. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
  104. package/dist/test/mcpl-reconnect-reregistration.test.js +22 -3
  105. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
  106. package/dist/test/mcpl-request-timeout.test.js +15 -0
  107. package/dist/test/mcpl-request-timeout.test.js.map +1 -1
  108. package/dist/test/offline-recovery-branch.test.d.ts +2 -0
  109. package/dist/test/offline-recovery-branch.test.d.ts.map +1 -0
  110. package/dist/test/offline-recovery-branch.test.js +226 -0
  111. package/dist/test/offline-recovery-branch.test.js.map +1 -0
  112. package/dist/test/present-while-acting.test.d.ts +19 -0
  113. package/dist/test/present-while-acting.test.d.ts.map +1 -0
  114. package/dist/test/present-while-acting.test.js +295 -0
  115. package/dist/test/present-while-acting.test.js.map +1 -0
  116. package/dist/test/primary-summary-refusal-fallback-integration.test.d.ts +2 -0
  117. package/dist/test/primary-summary-refusal-fallback-integration.test.d.ts.map +1 -0
  118. package/dist/test/primary-summary-refusal-fallback-integration.test.js +1955 -0
  119. package/dist/test/primary-summary-refusal-fallback-integration.test.js.map +1 -0
  120. package/dist/test/primary-summary-refusal-fallback.test.d.ts +2 -0
  121. package/dist/test/primary-summary-refusal-fallback.test.d.ts.map +1 -0
  122. package/dist/test/primary-summary-refusal-fallback.test.js +753 -0
  123. package/dist/test/primary-summary-refusal-fallback.test.js.map +1 -0
  124. package/dist/test/same-round-think-policy.test.d.ts +2 -0
  125. package/dist/test/same-round-think-policy.test.d.ts.map +1 -0
  126. package/dist/test/same-round-think-policy.test.js +853 -0
  127. package/dist/test/same-round-think-policy.test.js.map +1 -0
  128. package/dist/test/trunk-channel-routing.test.js +38 -0
  129. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  130. package/dist/test/workspace-read-image.test.d.ts +2 -0
  131. package/dist/test/workspace-read-image.test.d.ts.map +1 -0
  132. package/dist/test/workspace-read-image.test.js +518 -0
  133. package/dist/test/workspace-read-image.test.js.map +1 -0
  134. package/dist/tsconfig.tsbuildinfo +1 -1
  135. package/package.json +6 -4
  136. package/src/agent.ts +485 -63
  137. package/src/api/server.ts +8 -2
  138. package/src/canonical-json.ts +22 -0
  139. package/src/framework.ts +4096 -304
  140. package/src/gate/event-gate.ts +50 -4
  141. package/src/index.ts +20 -0
  142. package/src/mcpl/channel-registry.ts +396 -85
  143. package/src/mcpl/push-handler.ts +2 -0
  144. package/src/mcpl/server-connection.ts +173 -28
  145. package/src/mcpl/types.ts +49 -12
  146. package/src/modules/workspace/index.ts +759 -2
  147. package/src/modules/workspace/types.ts +5 -0
  148. package/src/recovery/discord-awareness-outbox.ts +459 -0
  149. package/src/recovery/offline-branch.ts +510 -0
  150. package/src/recovery/recover-cli.ts +130 -0
  151. package/src/types/agent.ts +44 -0
  152. package/src/types/events.ts +1 -1
  153. package/src/types/framework.ts +17 -0
  154. package/src/types/module.ts +33 -0
  155. package/src/types/trace.ts +4 -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
  },
@@ -114,10 +146,10 @@ const CHANNEL_TOOL_DEFINITIONS = [
114
146
  {
115
147
  name: 'think',
116
148
  description: 'Reason privately. The content stays in your own context and is NOT sent to any ' +
117
- 'channel or surface. This does NOT end or silence your turn: if you also write ' +
118
- 'plain text this turn, that text is still posted as your reply. Use think() purely ' +
119
- 'to work things out before (or instead of) speaking. To deliberately NOT reply this ' +
120
- 'turn, call skip_reply instead.',
149
+ 'channel or surface. Same-round routing of ordinary text beside think() depends on ' +
150
+ 'your current same_round_think_text_policy; inspect or change it with agent_settings. ' +
151
+ 'Use think() purely to work things out before (or instead of) speaking. To deliberately ' +
152
+ 'NOT reply this turn, call skip_reply instead.',
121
153
  inputSchema: {
122
154
  type: 'object',
123
155
  properties: {
@@ -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,13 @@ 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
+ * Provenance is kept so reconcile can tell a pure default (nobody ever
210
+ * decided) from a real decision (agent-tool, invitation-declined, …). */
211
+ desiredStates = new Map();
212
+ /** One-time migration inputs from the retired recipe auto-open policy. */
213
+ legacyPolicies = new Map();
214
+ migratedLegacyPolicies = new Set();
181
215
  constructor(serverRegistry, featureSetManager, pushEventFn, emitTraceFn, options) {
182
216
  this.serverRegistry = serverRegistry;
183
217
  this.featureSetManager = featureSetManager;
@@ -188,13 +222,16 @@ export class ChannelRegistry {
188
222
  this.onRouteFailure = options?.onRouteFailure;
189
223
  this.homeChannelResolver = options?.homeChannelResolver;
190
224
  this.activeChannelResolver = options?.activeChannelResolver;
225
+ this.store = options?.store;
226
+ this.initializeLifecycleStore();
191
227
  }
192
228
  /**
193
- * Set the channel auto-open policy for a server. Called by the framework
194
- * when each MCPL server is registered.
229
+ * Supply a legacy recipe policy for one-time migration into Chronicle.
195
230
  */
196
231
  setSubscriptionPolicy(serverId, policy) {
197
- this.subscriptionPolicies.set(serverId, policy);
232
+ // Backward-compatible recipe ingestion only. The policy is consumed once
233
+ // to seed Chronicle, then never applied to newly discovered channels.
234
+ this.legacyPolicies.set(serverId, policy);
198
235
  }
199
236
  // ==========================================================================
200
237
  // Handler Methods (called from framework.ts wireMcplEvents)
@@ -202,7 +239,7 @@ export class ChannelRegistry {
202
239
  /**
203
240
  * Handle `channels/register` from a server.
204
241
  *
205
- * Registers each channel descriptor in the map and auto-opens them.
242
+ * Registers descriptors and reconciles them to durable desired state.
206
243
  */
207
244
  async handleRegister(serverId, params, responder) {
208
245
  const registeredIds = [];
@@ -215,12 +252,14 @@ export class ChannelRegistry {
215
252
  });
216
253
  registeredIds.push(channel.id);
217
254
  }
218
- // Respond before auto-opening — the server blocks on this response and
255
+ // Respond before reconciliation — the server blocks on this response and
219
256
  // can't process channels/open until it arrives.
220
257
  const result = { registered: registeredIds };
221
258
  responder?.respond(result);
222
- // Auto-open all registered channels
223
- await this.autoOpenChannels(serverId, params.channels);
259
+ // One-time migration from the retired recipe policy, then reconcile the
260
+ // server to Chronicle-backed desired state.
261
+ this.migrateLegacyPolicy(serverId, params.channels);
262
+ await this.reconcileChannels(serverId, params.channels);
224
263
  this.emitTraceFn({
225
264
  type: 'mcpl:channels-register',
226
265
  serverId,
@@ -231,7 +270,7 @@ export class ChannelRegistry {
231
270
  /**
232
271
  * Handle `channels/changed` notification from a server.
233
272
  *
234
- * Processes added (register + auto-open), removed (delete + stop typing),
273
+ * Processes added (register + reconcile), removed (delete + stop typing),
235
274
  * and updated (replace descriptor) channels.
236
275
  */
237
276
  async handleChanged(serverId, params) {
@@ -253,7 +292,7 @@ export class ChannelRegistry {
253
292
  }
254
293
  }
255
294
  }
256
- // Process added channels (register + auto-open)
295
+ // Process added channels (register + reconcile durable desired state)
257
296
  if (params.added) {
258
297
  for (const channel of params.added) {
259
298
  const key = `${serverId}:${channel.id}`;
@@ -263,7 +302,7 @@ export class ChannelRegistry {
263
302
  open: false,
264
303
  });
265
304
  }
266
- await this.autoOpenChannels(serverId, params.added);
305
+ await this.reconcileChannels(serverId, params.added);
267
306
  }
268
307
  this.emitTraceFn({
269
308
  type: 'mcpl:channels-changed',
@@ -323,6 +362,13 @@ export class ChannelRegistry {
323
362
  label: channelLabel,
324
363
  });
325
364
  }
365
+ else {
366
+ // A server sending channels/incoming is authoritative evidence that
367
+ // the transport is actually open. This repairs transient status only;
368
+ // durable desired state still changes exclusively through lifecycle
369
+ // operations.
370
+ this.channels.get(incomingKey).open = true;
371
+ }
326
372
  // Determine whether to trigger inference
327
373
  let triggerInference = true;
328
374
  if (this.shouldTriggerInference) {
@@ -374,8 +420,9 @@ export class ChannelRegistry {
374
420
  });
375
421
  }
376
422
  /**
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.
423
+ * Ensure a channel is registered so it can serve as an outbound routing
424
+ * locus. A push event from a closed channel must never mutate lifecycle
425
+ * state: direct-address events are intentionally usable without subscribing.
379
426
  *
380
427
  * Mirrors the lazy-registration inside handleIncoming(), but for channels
381
428
  * that only ever arrive as push/events rather than channels/incoming — the
@@ -385,15 +432,11 @@ export class ChannelRegistry {
385
432
  * silently dropped even though the inbound message proves the channel is
386
433
  * reachable (item-3 redux, DM sub-case). Idempotent: a later authoritative
387
434
  * channels/register or channels/changed overwrites this descriptor with the
388
- * richer one; re-opening an already-open channel is a no-op.
435
+ * richer one.
389
436
  */
390
437
  ensureChannelRegistered(serverId, channelId, label) {
391
438
  const existing = this.findChannelEntry(channelId);
392
439
  if (existing) {
393
- if (!existing.open) {
394
- existing.open = true;
395
- this.emitTraceFn({ type: 'mcpl:channel-opened', serverId: existing.serverId, channelId });
396
- }
397
440
  return;
398
441
  }
399
442
  const key = `${serverId}:${channelId}`;
@@ -406,7 +449,7 @@ export class ChannelRegistry {
406
449
  direction: 'bidirectional',
407
450
  metadata: { lazyRegistered: true },
408
451
  },
409
- open: true,
452
+ open: false,
410
453
  });
411
454
  this.emitTraceFn({
412
455
  type: 'mcpl:channel-lazy-registered',
@@ -519,6 +562,12 @@ export class ChannelRegistry {
519
562
  getDescriptor(channelId) {
520
563
  return this.findChannelEntry(channelId)?.descriptor;
521
564
  }
565
+ isChannelOpen(channelId) {
566
+ return this.findChannelEntry(channelId)?.open === true;
567
+ }
568
+ getDesiredState(serverId, channelId) {
569
+ return this.desiredStates.get(this.lifecycleKey(serverId, channelId))?.state;
570
+ }
522
571
  /**
523
572
  * Get all open channels.
524
573
  */
@@ -551,6 +600,8 @@ export class ChannelRegistry {
551
600
  return this.handleToolOpen(input);
552
601
  case 'channel_close':
553
602
  return this.handleToolClose(input);
603
+ case 'channel_decline':
604
+ return this.handleToolDecline(input);
554
605
  case 'channel_publish':
555
606
  return this.handleToolPublish(input);
556
607
  case 'think':
@@ -626,7 +677,7 @@ export class ChannelRegistry {
626
677
  this.defaultPublishThreadId = undefined;
627
678
  }
628
679
  }
629
- this.subscriptionPolicies.delete(serverId);
680
+ // Desired state and migration markers deliberately survive disconnects.
630
681
  }
631
682
  /**
632
683
  * Stop all typing intervals and clear all channel registrations.
@@ -645,59 +696,145 @@ export class ChannelRegistry {
645
696
  this.defaultPublishThreadId = undefined;
646
697
  }
647
698
  // ==========================================================================
648
- // Private: Auto-open channels
699
+ // Private: Durable desired state and reconciliation
649
700
  // ==========================================================================
650
- async autoOpenChannels(serverId, channels) {
651
- const server = this.serverRegistry.getServer(serverId);
652
- if (!server)
701
+ lifecycleKey(serverId, channelId) {
702
+ return `${serverId}\u0000${channelId}`;
703
+ }
704
+ initializeLifecycleStore() {
705
+ if (!this.store)
653
706
  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
- });
707
+ try {
708
+ this.store.registerState({ id: CHANNEL_LIFECYCLE_LOG_ID, strategy: 'append_log' });
709
+ }
710
+ catch (error) {
711
+ if (!(error instanceof Error) || !error.message.includes('State already exists')) {
712
+ throw error;
713
+ }
714
+ }
715
+ const raw = this.store.getStateJson(CHANNEL_LIFECYCLE_LOG_ID);
716
+ if (!Array.isArray(raw))
662
717
  return;
718
+ for (const item of raw) {
719
+ if (!item || typeof item !== 'object')
720
+ continue;
721
+ const event = item;
722
+ if (typeof event.serverId !== 'string')
723
+ continue;
724
+ if (event.kind === 'desired-state' &&
725
+ typeof event.channelId === 'string' &&
726
+ (event.desired === 'open' || event.desired === 'closed')) {
727
+ this.desiredStates.set(this.lifecycleKey(event.serverId, event.channelId), { state: event.desired, source: typeof event.source === "string" ? event.source : "unknown" });
728
+ }
729
+ else if (event.kind === 'legacy-policy-migrated') {
730
+ this.migratedLegacyPolicies.add(event.serverId);
731
+ }
663
732
  }
664
- const allowList = Array.isArray(policy) ? new Set(policy) : null;
665
- let opened = 0;
733
+ }
734
+ appendLifecycleEvent(event) {
735
+ this.store?.appendToStateJson(CHANNEL_LIFECYCLE_LOG_ID, event);
736
+ }
737
+ setDesiredState(serverId, channelId, desired, source) {
738
+ const key = this.lifecycleKey(serverId, channelId);
739
+ if (this.desiredStates.get(key)?.state === desired)
740
+ return;
741
+ this.desiredStates.set(key, { state: desired, source });
742
+ this.appendLifecycleEvent({
743
+ kind: 'desired-state',
744
+ serverId,
745
+ channelId,
746
+ desired,
747
+ source,
748
+ timestamp: new Date().toISOString(),
749
+ });
750
+ }
751
+ /**
752
+ * Consume the old recipe policy exactly once. It seeds Chronicle for
753
+ * existing deployments, but is not an ongoing admission policy: channels
754
+ * discovered later use their server bootstrap preference, otherwise closed.
755
+ */
756
+ migrateLegacyPolicy(serverId, channels) {
757
+ if (this.migratedLegacyPolicies.has(serverId))
758
+ return;
759
+ const policy = this.legacyPolicies.get(serverId) ?? 'manual';
760
+ const allowList = Array.isArray(policy) ? new Set(policy) : undefined;
666
761
  for (const channel of channels) {
667
- if (allowList && !allowList.has(channel.id))
762
+ if (this.getDesiredState(serverId, channel.id))
668
763
  continue;
764
+ const desired = channel.initiallyOpen === true ||
765
+ policy === 'auto' || allowList?.has(channel.id)
766
+ ? 'open'
767
+ : 'closed';
768
+ this.setDesiredState(serverId, channel.id, desired, 'legacy-recipe-migration');
769
+ }
770
+ this.migratedLegacyPolicies.add(serverId);
771
+ this.appendLifecycleEvent({
772
+ kind: 'legacy-policy-migrated',
773
+ serverId,
774
+ source: Array.isArray(policy) ? 'allow-list' : policy,
775
+ timestamp: new Date().toISOString(),
776
+ });
777
+ }
778
+ ensureInitialDesiredState(serverId, channel) {
779
+ const existing = this.desiredStates.get(this.lifecycleKey(serverId, channel.id));
780
+ if (existing) {
781
+ // A server upgrading its descriptor to initiallyOpen may lift a pure
782
+ // default — 'default-closed' means nobody ever decided. Real decisions
783
+ // (agent-tool, invitation-declined, legacy migration, …) always stick.
784
+ if (channel.initiallyOpen === true && existing.state === 'closed' && existing.source === 'default-closed') {
785
+ this.setDesiredState(serverId, channel.id, 'open', 'server-bootstrap');
786
+ }
787
+ return;
788
+ }
789
+ this.setDesiredState(serverId, channel.id, channel.initiallyOpen === true ? 'open' : 'closed', channel.initiallyOpen === true ? 'server-bootstrap' : 'default-closed');
790
+ }
791
+ async reconcileChannels(serverId, channels) {
792
+ const server = this.serverRegistry.getServer(serverId);
793
+ if (!server)
794
+ return;
795
+ for (const channel of channels) {
796
+ this.ensureInitialDesiredState(serverId, channel);
669
797
  const key = `${serverId}:${channel.id}`;
798
+ const desired = this.getDesiredState(serverId, channel.id);
799
+ const entry = this.channels.get(key);
800
+ if (desired !== 'open') {
801
+ try {
802
+ await server.sendChannelsClose({ channelId: channel.id });
803
+ if (entry)
804
+ entry.open = false;
805
+ }
806
+ catch (err) {
807
+ this.emitTraceFn({
808
+ type: 'mcpl:channel-reconcile-failed',
809
+ serverId,
810
+ channelId: channel.id,
811
+ desired,
812
+ error: err.message,
813
+ });
814
+ }
815
+ continue;
816
+ }
670
817
  try {
671
818
  await server.sendChannelsOpen({
819
+ channelId: channel.id,
672
820
  type: channel.type,
673
821
  address: channel.address,
674
822
  });
675
- const entry = this.channels.get(key);
676
- if (entry) {
823
+ if (entry)
677
824
  entry.open = true;
678
- }
679
- opened++;
680
825
  }
681
826
  catch (err) {
827
+ if (entry)
828
+ entry.open = false;
682
829
  this.emitTraceFn({
683
- type: 'mcpl:channel-open-failed',
830
+ type: 'mcpl:channel-reconcile-failed',
684
831
  serverId,
685
832
  channelId: channel.id,
833
+ desired,
686
834
  error: err.message,
687
835
  });
688
836
  }
689
837
  }
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
838
  }
702
839
  // ==========================================================================
703
840
  // Private: Typing notification
@@ -734,6 +871,22 @@ export class ChannelRegistry {
734
871
  }
735
872
  return undefined;
736
873
  }
874
+ resolveToolChannelEntry(channelId, serverId) {
875
+ const matches = [...this.channels.values()].filter((entry) => entry.descriptor.id === channelId && (!serverId || entry.serverId === serverId));
876
+ if (matches.length === 0) {
877
+ return {
878
+ error: serverId
879
+ ? `Channel not found: ${channelId} on server ${serverId}`
880
+ : `Channel not found: ${channelId}`,
881
+ };
882
+ }
883
+ if (matches.length > 1) {
884
+ return {
885
+ error: `Channel id is ambiguous across MCPL servers: ${channelId}. Include serverId.`,
886
+ };
887
+ }
888
+ return { entry: matches[0] };
889
+ }
737
890
  /**
738
891
  * Find the composite key for a channel by its channelId.
739
892
  */
@@ -757,6 +910,7 @@ export class ChannelRegistry {
757
910
  label: entry.descriptor.label,
758
911
  direction: entry.descriptor.direction,
759
912
  open: entry.open,
913
+ desired: this.getDesiredState(entry.serverId, entry.descriptor.id) ?? 'unknown',
760
914
  serverId: entry.serverId,
761
915
  });
762
916
  }
@@ -766,20 +920,23 @@ export class ChannelRegistry {
766
920
  };
767
921
  }
768
922
  async handleToolOpen(input) {
769
- const entry = this.findChannelEntry(input.channelId);
923
+ const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
924
+ const entry = resolved.entry;
770
925
  if (!entry) {
771
926
  return {
772
927
  success: false,
773
- error: `Channel not found: ${input.channelId}`,
928
+ error: resolved.error,
774
929
  isError: true,
775
930
  };
776
931
  }
777
- if (entry.open) {
932
+ const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open';
933
+ if (entry.open && alreadyDesiredOpen && !input.backscroll) {
778
934
  return {
779
935
  success: true,
780
936
  data: { channelId: input.channelId, status: 'already open' },
781
937
  };
782
938
  }
939
+ this.setDesiredState(entry.serverId, input.channelId, 'open', 'agent-tool');
783
940
  const server = this.serverRegistry.getServer(entry.serverId);
784
941
  if (!server) {
785
942
  return {
@@ -789,14 +946,27 @@ export class ChannelRegistry {
789
946
  };
790
947
  }
791
948
  try {
792
- await server.sendChannelsOpen({
949
+ const history = input.backscroll
950
+ ? {
951
+ limit: Math.max(0, Math.min(500, Math.floor(input.backscroll))),
952
+ ...(input.beforeMessageId ? { beforeMessageId: input.beforeMessageId } : {}),
953
+ }
954
+ : undefined;
955
+ const result = await server.sendChannelsOpen({
956
+ channelId: entry.descriptor.id,
793
957
  type: entry.descriptor.type,
794
958
  address: entry.descriptor.address,
959
+ ...(history ? { history } : {}),
795
960
  });
796
961
  entry.open = true;
797
962
  return {
798
963
  success: true,
799
- data: { channelId: input.channelId, status: 'opened' },
964
+ data: {
965
+ channelId: input.channelId,
966
+ status: alreadyDesiredOpen ? 'reconciled' : 'opened',
967
+ ...(result.history ? { history: result.history } : {}),
968
+ ...(result.historyTruncated ? { historyTruncated: true } : {}),
969
+ },
800
970
  };
801
971
  }
802
972
  catch (err) {
@@ -808,20 +978,24 @@ export class ChannelRegistry {
808
978
  }
809
979
  }
810
980
  async handleToolClose(input) {
811
- const entry = this.findChannelEntry(input.channelId);
981
+ const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
982
+ const entry = resolved.entry;
812
983
  if (!entry) {
813
984
  return {
814
985
  success: false,
815
- error: `Channel not found: ${input.channelId}`,
986
+ error: resolved.error,
816
987
  isError: true,
817
988
  };
818
989
  }
819
- if (!entry.open) {
990
+ const alreadyDesiredClosed = this.getDesiredState(entry.serverId, input.channelId) === 'closed';
991
+ if (!entry.open && alreadyDesiredClosed) {
820
992
  return {
821
993
  success: true,
822
994
  data: { channelId: input.channelId, status: 'already closed' },
823
995
  };
824
996
  }
997
+ this.setDesiredState(entry.serverId, input.channelId, 'closed', 'agent-tool');
998
+ this.stopTyping(input.channelId);
825
999
  const server = this.serverRegistry.getServer(entry.serverId);
826
1000
  if (!server) {
827
1001
  return {
@@ -833,7 +1007,6 @@ export class ChannelRegistry {
833
1007
  try {
834
1008
  await server.sendChannelsClose({ channelId: input.channelId });
835
1009
  entry.open = false;
836
- this.stopTyping(input.channelId);
837
1010
  return {
838
1011
  success: true,
839
1012
  data: { channelId: input.channelId, status: 'closed' },
@@ -847,6 +1020,69 @@ export class ChannelRegistry {
847
1020
  };
848
1021
  }
849
1022
  }
1023
+ async handleToolDecline(input) {
1024
+ const resolved = this.resolveToolChannelEntry(input.channelId, input.serverId);
1025
+ const entry = resolved.entry;
1026
+ if (!entry) {
1027
+ return { success: false, error: resolved.error, isError: true };
1028
+ }
1029
+ if (entry.open || this.getDesiredState(entry.serverId, input.channelId) === 'open') {
1030
+ return {
1031
+ success: false,
1032
+ error: `Channel is already open: ${input.channelId}. Use channel_close to leave it.`,
1033
+ isError: true,
1034
+ };
1035
+ }
1036
+ // The lifecycle decision is authoritative even if the optional public
1037
+ // acknowledgment cannot be rendered by the surface.
1038
+ this.setDesiredState(entry.serverId, input.channelId, 'closed', 'invitation-declined');
1039
+ let acknowledged = false;
1040
+ let representation;
1041
+ let acknowledgmentError;
1042
+ if (input.acknowledge) {
1043
+ const server = this.serverRegistry.getServer(entry.serverId);
1044
+ if (!server) {
1045
+ acknowledgmentError = `Server not found: ${entry.serverId}`;
1046
+ }
1047
+ else {
1048
+ try {
1049
+ const result = await server.sendChannelsAcknowledge({
1050
+ channelId: input.channelId,
1051
+ messageId: input.messageId,
1052
+ intent: 'seen-not-opening',
1053
+ value: input.acknowledge,
1054
+ });
1055
+ acknowledged = result.acknowledged;
1056
+ representation = result.representation;
1057
+ if (!acknowledged) {
1058
+ acknowledgmentError = result.reason ??
1059
+ 'The channel integration could not post the acknowledgment.';
1060
+ }
1061
+ }
1062
+ catch (error) {
1063
+ acknowledgmentError = error.message;
1064
+ }
1065
+ }
1066
+ }
1067
+ this.appendLifecycleEvent({
1068
+ kind: 'invitation-declined',
1069
+ serverId: entry.serverId,
1070
+ channelId: input.channelId,
1071
+ messageId: input.messageId,
1072
+ acknowledgment: representation ?? input.acknowledge,
1073
+ timestamp: new Date().toISOString(),
1074
+ });
1075
+ return {
1076
+ success: true,
1077
+ data: {
1078
+ channelId: input.channelId,
1079
+ status: 'remained closed',
1080
+ acknowledged,
1081
+ ...(representation ? { representation } : {}),
1082
+ ...(acknowledgmentError ? { acknowledgmentError } : {}),
1083
+ },
1084
+ };
1085
+ }
850
1086
  /**
851
1087
  * Handle the synthesized `think` tool — a private reasoning scratchpad. It
852
1088
  * sends nothing, and (unlike before) does NOT silence the turn: trailing
@@ -856,11 +1092,13 @@ export class ChannelRegistry {
856
1092
  handleToolThink(input) {
857
1093
  return {
858
1094
  success: true,
1095
+ // Same echo-avoidance as skip_reply: the thought text is already in the
1096
+ // tool_use block; don't duplicate it in the result.
859
1097
  data: {
860
1098
  noted: true,
861
- note: 'Thought recorded (private — not sent anywhere). This did NOT silence your turn: ' +
862
- 'write plain text to reply, or call skip_reply to end the turn without replying.',
863
- content: typeof input?.content === 'string' ? input.content : undefined,
1099
+ note: 'Thought recorded (private — not sent anywhere). Same-round text routing depends on ' +
1100
+ 'your current same_round_think_text_policy; use agent_settings get to inspect it, or ' +
1101
+ 'call skip_reply to end the turn without replying.',
864
1102
  },
865
1103
  };
866
1104
  }
@@ -874,10 +1112,18 @@ export class ChannelRegistry {
874
1112
  handleToolSkipReply(input) {
875
1113
  return {
876
1114
  success: true,
1115
+ // The note says "ended the turn" — make it TRUE. Without endTurn the
1116
+ // framework resumes the stream after the tool result, and a model with
1117
+ // nothing to say (told its turn already ended) just calls skip_reply
1118
+ // again: observed as a 40+ round skip_reply loop on Fable 5, burning a
1119
+ // round-trip + ~70 tokens per iteration until something kills the turn.
1120
+ endTurn: true,
1121
+ // Deliberately terse: the agent's `reason` is already in the tool_use
1122
+ // block — echoing it back doubled every skip turn's footprint (observed
1123
+ // on Fable: ~60% of skip_reply result bytes were verbatim input echo).
877
1124
  data: {
878
1125
  skipped: true,
879
- note: 'Ended the turn without replying — nothing was sent to any channel.',
880
- reason: typeof input?.reason === 'string' ? input.reason : undefined,
1126
+ note: 'Turn ended; nothing sent.',
881
1127
  },
882
1128
  };
883
1129
  }