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