@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.
- package/README.md +78 -0
- package/dist/src/agent.d.ts +56 -2
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +372 -57
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/mcp-server.js +0 -0
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +7 -2
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/canonical-json.d.ts +2 -0
- package/dist/src/canonical-json.d.ts.map +1 -0
- package/dist/src/canonical-json.js +23 -0
- package/dist/src/canonical-json.js.map +1 -0
- package/dist/src/framework.d.ts +160 -2
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +3098 -167
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +34 -4
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +36 -15
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +322 -76
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +1 -0
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +1 -0
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +39 -4
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +146 -28
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +43 -12
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js +1 -0
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +12 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +661 -2
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +4 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.d.ts +99 -0
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -0
- package/dist/src/recovery/discord-awareness-outbox.js +334 -0
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -0
- package/dist/src/recovery/offline-branch.d.ts +45 -0
- package/dist/src/recovery/offline-branch.d.ts.map +1 -0
- package/dist/src/recovery/offline-branch.js +362 -0
- package/dist/src/recovery/offline-branch.js.map +1 -0
- package/dist/src/recovery/recover-cli.d.ts +3 -0
- package/dist/src/recovery/recover-cli.d.ts.map +1 -0
- package/dist/src/recovery/recover-cli.js +129 -0
- package/dist/src/recovery/recover-cli.js.map +1 -0
- package/dist/src/types/agent.d.ts +37 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +1 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +14 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +31 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +4 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/agent-runtime-settings.test.d.ts +2 -0
- package/dist/test/agent-runtime-settings.test.d.ts.map +1 -0
- package/dist/test/agent-runtime-settings.test.js +227 -0
- package/dist/test/agent-runtime-settings.test.js.map +1 -0
- package/dist/test/busy-agent-scheduler.test.d.ts +2 -0
- package/dist/test/busy-agent-scheduler.test.d.ts.map +1 -0
- package/dist/test/busy-agent-scheduler.test.js +62 -0
- package/dist/test/busy-agent-scheduler.test.js.map +1 -0
- package/dist/test/channel-lifecycle-provenance.test.d.ts +2 -0
- package/dist/test/channel-lifecycle-provenance.test.d.ts.map +1 -0
- package/dist/test/channel-lifecycle-provenance.test.js +47 -0
- package/dist/test/channel-lifecycle-provenance.test.js.map +1 -0
- package/dist/test/channel-registry-routing.test.js +5 -4
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/conversation-fork-injection-scoping.test.js +12 -1
- package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
- package/dist/test/discord-awareness-outbox.test.d.ts +2 -0
- package/dist/test/discord-awareness-outbox.test.d.ts.map +1 -0
- package/dist/test/discord-awareness-outbox.test.js +248 -0
- package/dist/test/discord-awareness-outbox.test.js.map +1 -0
- package/dist/test/event-gate.test.js +21 -1
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/helpers/mock-membrane.d.ts +13 -1
- package/dist/test/helpers/mock-membrane.d.ts.map +1 -1
- package/dist/test/helpers/mock-membrane.js +9 -2
- package/dist/test/helpers/mock-membrane.js.map +1 -1
- package/dist/test/maintenance-timer.test.js +103 -1
- package/dist/test/maintenance-timer.test.js.map +1 -1
- package/dist/test/mcpl-awareness-barrier.test.d.ts +2 -0
- package/dist/test/mcpl-awareness-barrier.test.d.ts.map +1 -0
- package/dist/test/mcpl-awareness-barrier.test.js +845 -0
- package/dist/test/mcpl-awareness-barrier.test.js.map +1 -0
- package/dist/test/mcpl-gate-roundtrip.test.js +138 -6
- package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
- package/dist/test/mcpl-reconnect-reregistration.test.js +22 -3
- package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
- package/dist/test/mcpl-request-timeout.test.js +15 -0
- package/dist/test/mcpl-request-timeout.test.js.map +1 -1
- package/dist/test/offline-recovery-branch.test.d.ts +2 -0
- package/dist/test/offline-recovery-branch.test.d.ts.map +1 -0
- package/dist/test/offline-recovery-branch.test.js +226 -0
- package/dist/test/offline-recovery-branch.test.js.map +1 -0
- package/dist/test/present-while-acting.test.d.ts +19 -0
- package/dist/test/present-while-acting.test.d.ts.map +1 -0
- package/dist/test/present-while-acting.test.js +295 -0
- package/dist/test/present-while-acting.test.js.map +1 -0
- package/dist/test/primary-summary-refusal-fallback-integration.test.d.ts +2 -0
- package/dist/test/primary-summary-refusal-fallback-integration.test.d.ts.map +1 -0
- package/dist/test/primary-summary-refusal-fallback-integration.test.js +1955 -0
- package/dist/test/primary-summary-refusal-fallback-integration.test.js.map +1 -0
- package/dist/test/primary-summary-refusal-fallback.test.d.ts +2 -0
- package/dist/test/primary-summary-refusal-fallback.test.d.ts.map +1 -0
- package/dist/test/primary-summary-refusal-fallback.test.js +753 -0
- package/dist/test/primary-summary-refusal-fallback.test.js.map +1 -0
- package/dist/test/same-round-think-policy.test.d.ts +2 -0
- package/dist/test/same-round-think-policy.test.d.ts.map +1 -0
- package/dist/test/same-round-think-policy.test.js +853 -0
- package/dist/test/same-round-think-policy.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +38 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/test/workspace-read-image.test.d.ts +2 -0
- package/dist/test/workspace-read-image.test.d.ts.map +1 -0
- package/dist/test/workspace-read-image.test.js +518 -0
- package/dist/test/workspace-read-image.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -4
- package/src/agent.ts +485 -63
- package/src/api/server.ts +8 -2
- package/src/canonical-json.ts +22 -0
- package/src/framework.ts +4096 -304
- package/src/gate/event-gate.ts +50 -4
- package/src/index.ts +20 -0
- package/src/mcpl/channel-registry.ts +396 -85
- package/src/mcpl/push-handler.ts +2 -0
- package/src/mcpl/server-connection.ts +173 -28
- package/src/mcpl/types.ts +49 -12
- package/src/modules/workspace/index.ts +759 -2
- package/src/modules/workspace/types.ts +5 -0
- package/src/recovery/discord-awareness-outbox.ts +459 -0
- package/src/recovery/offline-branch.ts +510 -0
- package/src/recovery/recover-cli.ts +130 -0
- package/src/types/agent.ts +44 -0
- package/src/types/events.ts +1 -1
- package/src/types/framework.ts +17 -0
- package/src/types/module.ts +33 -0
- 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
|
-
* -
|
|
9
|
+
* - Reconcile actual channel state to Chronicle-backed desired state
|
|
10
10
|
* - Route incoming messages to the processing queue
|
|
11
11
|
* - Manage typing indicator timers (7s interval for Discord compatibility)
|
|
12
12
|
* - Expose synthesized tools: channel_list, channel_open, channel_close, channel_publish
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive)
|
|
17
17
|
// ============================================================================
|
|
18
18
|
const TYPING_INTERVAL_MS = 7_000;
|
|
19
|
+
const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle';
|
|
19
20
|
function shallowEqualRecord(a, b) {
|
|
20
21
|
if (a === b)
|
|
21
22
|
return true;
|
|
@@ -78,15 +79,45 @@ const CHANNEL_TOOL_DEFINITIONS = [
|
|
|
78
79
|
},
|
|
79
80
|
{
|
|
80
81
|
name: 'channel_open',
|
|
81
|
-
description: 'Open a channel to start receiving
|
|
82
|
+
description: 'Open a channel to start receiving its ordinary ongoing traffic. The MCPL ' +
|
|
83
|
+
'integration performs its own subscribe/join/attach operation. Optionally request ' +
|
|
84
|
+
'history preceding the message that invited you into the channel.',
|
|
82
85
|
inputSchema: {
|
|
83
86
|
type: 'object',
|
|
84
87
|
properties: {
|
|
85
88
|
channelId: { type: 'string', description: 'ID of the channel to open' },
|
|
89
|
+
serverId: { type: 'string', description: 'Owning MCPL server; required only when channelId is ambiguous.' },
|
|
90
|
+
backscroll: {
|
|
91
|
+
type: 'number',
|
|
92
|
+
description: 'Number of earlier messages to return while opening (0-500; default 0).',
|
|
93
|
+
},
|
|
94
|
+
beforeMessageId: {
|
|
95
|
+
type: 'string',
|
|
96
|
+
description: 'Anchor message from the closed-channel notice; it is excluded from backscroll.',
|
|
97
|
+
},
|
|
86
98
|
},
|
|
87
99
|
required: ['channelId'],
|
|
88
100
|
},
|
|
89
101
|
},
|
|
102
|
+
{
|
|
103
|
+
name: 'channel_decline',
|
|
104
|
+
description: 'Deliberately remain closed after being addressed in a closed channel. Optionally ' +
|
|
105
|
+
'post a public acknowledgment through the MCPL integration. Acknowledgment is ' +
|
|
106
|
+
'opt-in; omitting it declines silently.',
|
|
107
|
+
inputSchema: {
|
|
108
|
+
type: 'object',
|
|
109
|
+
properties: {
|
|
110
|
+
channelId: { type: 'string', description: 'Channel from the invitation notice.' },
|
|
111
|
+
serverId: { type: 'string', description: 'Owning MCPL server from the invitation notice.' },
|
|
112
|
+
messageId: { type: 'string', description: 'Triggering message to acknowledge.' },
|
|
113
|
+
acknowledge: {
|
|
114
|
+
type: 'string',
|
|
115
|
+
description: 'Optional surface value such as 👀. Omit for a silent decline.',
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
required: ['channelId', 'messageId'],
|
|
119
|
+
},
|
|
120
|
+
},
|
|
90
121
|
{
|
|
91
122
|
name: 'channel_close',
|
|
92
123
|
description: 'Close a channel to stop receiving messages',
|
|
@@ -94,6 +125,7 @@ const CHANNEL_TOOL_DEFINITIONS = [
|
|
|
94
125
|
type: 'object',
|
|
95
126
|
properties: {
|
|
96
127
|
channelId: { type: 'string', description: 'ID of the channel to close' },
|
|
128
|
+
serverId: { type: 'string', description: 'Owning MCPL server; required only when channelId is ambiguous.' },
|
|
97
129
|
},
|
|
98
130
|
required: ['channelId'],
|
|
99
131
|
},
|
|
@@ -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.
|
|
118
|
-
'
|
|
119
|
-
'to work things out before (or instead of) speaking. To deliberately
|
|
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
|
-
*
|
|
177
|
-
*
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
223
|
-
|
|
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 +
|
|
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 +
|
|
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.
|
|
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
|
|
378
|
-
*
|
|
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
|
|
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:
|
|
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
|
-
|
|
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:
|
|
699
|
+
// Private: Durable desired state and reconciliation
|
|
649
700
|
// ==========================================================================
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
701
|
+
lifecycleKey(serverId, channelId) {
|
|
702
|
+
return `${serverId}\u0000${channelId}`;
|
|
703
|
+
}
|
|
704
|
+
initializeLifecycleStore() {
|
|
705
|
+
if (!this.store)
|
|
653
706
|
return;
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
665
|
-
|
|
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 (
|
|
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
|
-
|
|
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-
|
|
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
|
|
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:
|
|
928
|
+
error: resolved.error,
|
|
774
929
|
isError: true,
|
|
775
930
|
};
|
|
776
931
|
}
|
|
777
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
|
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:
|
|
986
|
+
error: resolved.error,
|
|
816
987
|
isError: true,
|
|
817
988
|
};
|
|
818
989
|
}
|
|
819
|
-
|
|
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).
|
|
862
|
-
'
|
|
863
|
-
|
|
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: '
|
|
880
|
-
reason: typeof input?.reason === 'string' ? input.reason : undefined,
|
|
1126
|
+
note: 'Turn ended; nothing sent.',
|
|
881
1127
|
},
|
|
882
1128
|
};
|
|
883
1129
|
}
|