@animalabs/agent-framework 0.8.0 → 0.9.0
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/dist/src/agent.d.ts +10 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +33 -3
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +171 -15
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1059 -224
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +11 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +66 -0
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +115 -0
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -0
- package/dist/src/mcpl/capability-grant.js +291 -0
- package/dist/src/mcpl/capability-grant.js.map +1 -0
- package/dist/src/mcpl/capability-mask.d.ts +51 -0
- package/dist/src/mcpl/capability-mask.d.ts.map +1 -0
- package/dist/src/mcpl/capability-mask.js +190 -0
- package/dist/src/mcpl/capability-mask.js.map +1 -0
- package/dist/src/mcpl/channel-registry.d.ts +3 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +163 -36
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.d.ts +8 -3
- package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.js +28 -8
- package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
- package/dist/src/mcpl/errors.d.ts +6 -0
- package/dist/src/mcpl/errors.d.ts.map +1 -1
- package/dist/src/mcpl/errors.js +6 -0
- package/dist/src/mcpl/errors.js.map +1 -1
- package/dist/src/mcpl/feature-set-manager.d.ts +10 -1
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
- package/dist/src/mcpl/feature-set-manager.js +45 -4
- package/dist/src/mcpl/feature-set-manager.js.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts +10 -14
- package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.js +58 -67
- package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +1 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +9 -0
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
- package/dist/src/mcpl/prose-stream-router.js +16 -0
- package/dist/src/mcpl/prose-stream-router.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +20 -2
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +93 -9
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +231 -32
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +4 -3
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tags.d.ts +24 -0
- package/dist/src/mcpl/tags.d.ts.map +1 -0
- package/dist/src/mcpl/tags.js +52 -0
- package/dist/src/mcpl/tags.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +10 -0
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +159 -64
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js +16 -2
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/refusal-reactions.d.ts +27 -0
- package/dist/src/refusal-reactions.d.ts.map +1 -0
- package/dist/src/refusal-reactions.js +35 -0
- package/dist/src/refusal-reactions.js.map +1 -0
- package/dist/src/tool-result-history.d.ts +10 -0
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +16 -5
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/agent.d.ts +35 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +16 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +24 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/channel-registry-routing.test.js +38 -10
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/channel-registry-typing.test.js +10 -1
- package/dist/test/channel-registry-typing.test.js.map +1 -1
- package/dist/test/checkpoint-attribution.test.js +168 -8
- package/dist/test/checkpoint-attribution.test.js.map +1 -1
- package/dist/test/conversation-fork-injection-scoping.test.js +8 -3
- package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
- package/dist/test/event-gate-selfwake.test.d.ts +15 -0
- package/dist/test/event-gate-selfwake.test.d.ts.map +1 -0
- package/dist/test/event-gate-selfwake.test.js +125 -0
- package/dist/test/event-gate-selfwake.test.js.map +1 -0
- package/dist/test/machine-close-provenance.test.js +6 -0
- package/dist/test/machine-close-provenance.test.js.map +1 -1
- package/dist/test/mcpl-awareness-barrier.test.js +10 -0
- package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
- package/dist/test/mcpl-capability-grant.test.d.ts +10 -0
- package/dist/test/mcpl-capability-grant.test.d.ts.map +1 -0
- package/dist/test/mcpl-capability-grant.test.js +109 -0
- package/dist/test/mcpl-capability-grant.test.js.map +1 -0
- package/dist/test/mcpl-capability-scoping.test.d.ts +2 -0
- package/dist/test/mcpl-capability-scoping.test.d.ts.map +1 -0
- package/dist/test/mcpl-capability-scoping.test.js +266 -0
- package/dist/test/mcpl-capability-scoping.test.js.map +1 -0
- package/dist/test/mcpl-gate-roundtrip.test.js +13 -0
- package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
- package/dist/test/mcpl-list-status.test.d.ts +2 -0
- package/dist/test/mcpl-list-status.test.d.ts.map +1 -0
- package/dist/test/mcpl-list-status.test.js +78 -0
- package/dist/test/mcpl-list-status.test.js.map +1 -0
- package/dist/test/mcpl-manifest-changed.test.d.ts +2 -0
- package/dist/test/mcpl-manifest-changed.test.d.ts.map +1 -0
- package/dist/test/mcpl-manifest-changed.test.js +177 -0
- package/dist/test/mcpl-manifest-changed.test.js.map +1 -0
- package/dist/test/mcpl-reconnect-events.test.js +27 -2
- package/dist/test/mcpl-reconnect-events.test.js.map +1 -1
- package/dist/test/mcpl-reconnect-reregistration.test.js +53 -17
- package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
- package/dist/test/mcpl-request-timeout.test.js +1 -1
- package/dist/test/mcpl-request-timeout.test.js.map +1 -1
- package/dist/test/physical-window-restart.test.d.ts +14 -0
- package/dist/test/physical-window-restart.test.d.ts.map +1 -0
- package/dist/test/physical-window-restart.test.js +222 -0
- package/dist/test/physical-window-restart.test.js.map +1 -0
- package/dist/test/present-while-acting.test.js +199 -1
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/refusal-reaction-baseline.test.d.ts +2 -0
- package/dist/test/refusal-reaction-baseline.test.d.ts.map +1 -0
- package/dist/test/refusal-reaction-baseline.test.js +74 -0
- package/dist/test/refusal-reaction-baseline.test.js.map +1 -0
- package/dist/test/state-scaling-e2e.test.js +7 -1
- package/dist/test/state-scaling-e2e.test.js.map +1 -1
- package/dist/test/state-scaling.test.js +9 -1
- package/dist/test/state-scaling.test.js.map +1 -1
- package/dist/test/tool-result-spill.test.d.ts +13 -0
- package/dist/test/tool-result-spill.test.d.ts.map +1 -0
- package/dist/test/tool-result-spill.test.js +463 -0
- package/dist/test/tool-result-spill.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +45 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/agent.ts +37 -3
- package/src/framework.ts +1219 -248
- package/src/gate/event-gate.ts +72 -0
- package/src/index.ts +1 -0
- package/src/mcpl/capability-grant.ts +319 -0
- package/src/mcpl/capability-mask.ts +228 -0
- package/src/mcpl/channel-registry.ts +167 -39
- package/src/mcpl/checkpoint-manager.ts +28 -8
- package/src/mcpl/errors.ts +7 -0
- package/src/mcpl/feature-set-manager.ts +50 -5
- package/src/mcpl/hook-orchestrator.ts +64 -86
- package/src/mcpl/index.ts +0 -2
- package/src/mcpl/prose-stream-router.ts +17 -0
- package/src/mcpl/push-handler.ts +18 -2
- package/src/mcpl/server-connection.ts +262 -34
- package/src/mcpl/server-registry.ts +4 -3
- package/src/mcpl/tags.ts +50 -0
- package/src/mcpl/transport.ts +11 -0
- package/src/mcpl/types.ts +176 -78
- package/src/refusal-reactions.ts +37 -0
- package/src/tool-result-history.ts +17 -5
- package/src/types/agent.ts +36 -0
- package/src/types/framework.ts +17 -0
- package/src/types/trace.ts +22 -0
|
@@ -34,6 +34,8 @@ import type {
|
|
|
34
34
|
import type { McplServerRegistry } from './server-registry.js';
|
|
35
35
|
import type { FeatureSetManager } from './feature-set-manager.js';
|
|
36
36
|
import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js';
|
|
37
|
+
import { expandCoreTags } from './tags.js';
|
|
38
|
+
import { CapabilityGrant } from './capability-grant.js';
|
|
37
39
|
|
|
38
40
|
// ============================================================================
|
|
39
41
|
// Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive)
|
|
@@ -253,7 +255,8 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
253
255
|
'End your turn WITHOUT sending anything to any channel or surface. Use when you have ' +
|
|
254
256
|
'read the messages but deliberately choose not to reply right now — ambient chatter, ' +
|
|
255
257
|
'nothing to add, or you are waiting. Any plain text you wrote this turn stays private ' +
|
|
256
|
-
'and is NOT posted. To reply instead, just write plain text (no tool call).'
|
|
258
|
+
'and is NOT posted. To reply instead, just write plain text (no tool call). ' +
|
|
259
|
+
'To end this turn but come back on your own shortly, set wake_in_seconds.',
|
|
257
260
|
inputSchema: {
|
|
258
261
|
type: 'object' as const,
|
|
259
262
|
properties: {
|
|
@@ -261,6 +264,13 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
261
264
|
type: 'string',
|
|
262
265
|
description: 'Optional private note on why you are not replying (not sent anywhere).',
|
|
263
266
|
},
|
|
267
|
+
wake_in_seconds: {
|
|
268
|
+
type: 'number',
|
|
269
|
+
description:
|
|
270
|
+
'Optional self-wake: if nothing else wakes you first, you wake again after this ' +
|
|
271
|
+
'many seconds (1 = go again almost immediately; clamped to 1–3600). Any other ' +
|
|
272
|
+
'wake before then cancels it. Omit to stay idle until the next external wake.',
|
|
273
|
+
},
|
|
264
274
|
},
|
|
265
275
|
required: [],
|
|
266
276
|
},
|
|
@@ -458,8 +468,19 @@ export class ChannelRegistry {
|
|
|
458
468
|
responder?: Responder,
|
|
459
469
|
): Promise<void> {
|
|
460
470
|
const registeredIds: string[] = [];
|
|
471
|
+
// §14.5: per-descriptor authorization with ITEMIZED results — one entry
|
|
472
|
+
// per submitted descriptor. Strict 0.5 servers treat a result without
|
|
473
|
+
// `results` (or a descriptor missing from it) as rejected, so the shape
|
|
474
|
+
// is interop-critical, not decorative. Rejection here is per-descriptor
|
|
475
|
+
// validation; the method-level channels.register gate already ran at the
|
|
476
|
+
// connection (§14.1).
|
|
477
|
+
const results: ChannelsRegisterResult['results'] = [];
|
|
461
478
|
|
|
462
479
|
for (const channel of params.channels) {
|
|
480
|
+
if (!channel || typeof channel.id !== 'string' || channel.id.length === 0) {
|
|
481
|
+
results.push({ id: String(channel?.id ?? ''), accepted: false, reason: 'invalid descriptor: missing id' });
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
463
484
|
const key = `${serverId}:${channel.id}`;
|
|
464
485
|
this.channels.set(key, {
|
|
465
486
|
serverId,
|
|
@@ -467,11 +488,12 @@ export class ChannelRegistry {
|
|
|
467
488
|
open: false,
|
|
468
489
|
});
|
|
469
490
|
registeredIds.push(channel.id);
|
|
491
|
+
results.push({ id: channel.id, accepted: true });
|
|
470
492
|
}
|
|
471
493
|
|
|
472
494
|
// Respond before reconciliation — the server blocks on this response and
|
|
473
495
|
// can't process channels/open until it arrives.
|
|
474
|
-
const result: ChannelsRegisterResult = { registered: registeredIds };
|
|
496
|
+
const result: ChannelsRegisterResult = { registered: registeredIds, results };
|
|
475
497
|
responder?.respond(result);
|
|
476
498
|
|
|
477
499
|
// One-time migration from the retired recipe policy, then reconcile the
|
|
@@ -496,39 +518,69 @@ export class ChannelRegistry {
|
|
|
496
518
|
async handleChanged(
|
|
497
519
|
serverId: string,
|
|
498
520
|
params: ChannelsChangedParams,
|
|
521
|
+
responder?: Responder,
|
|
499
522
|
): Promise<void> {
|
|
500
|
-
//
|
|
523
|
+
// §14.5: dual-mode. Request form answers ITEMIZED per-descriptor
|
|
524
|
+
// results for added channels; Notification form filters itemwise with a
|
|
525
|
+
// diagnostic. Either way, added descriptors are validated exactly like
|
|
526
|
+
// channels/register — a changed-set cannot smuggle in what register
|
|
527
|
+
// would have rejected (PR #79 review blocker 5).
|
|
528
|
+
const addedResults: Array<{ id: string; accepted: boolean; reason?: string }> = [];
|
|
529
|
+
// Process removed channels — itemized truthfully: removal of a channel
|
|
530
|
+
// we never had is reported, not silently accepted.
|
|
501
531
|
if (params.removed) {
|
|
502
532
|
for (const channelId of params.removed) {
|
|
503
533
|
const key = `${serverId}:${channelId}`;
|
|
504
|
-
this.channels.delete(key);
|
|
534
|
+
const existed = this.channels.delete(key);
|
|
505
535
|
this.stopTyping(channelId);
|
|
536
|
+
addedResults.push({ id: channelId, accepted: existed, reason: existed ? undefined : 'not registered' });
|
|
506
537
|
}
|
|
507
538
|
}
|
|
508
539
|
|
|
509
|
-
// Process updated channels
|
|
540
|
+
// Process updated channels — validated itemwise exactly like added
|
|
541
|
+
// (§14.5: a changed-set cannot smuggle in what register would reject),
|
|
542
|
+
// and every submitted descriptor gets a verdict: a strict server reads
|
|
543
|
+
// absence from `results` as rejection.
|
|
510
544
|
if (params.updated) {
|
|
511
545
|
for (const channel of params.updated) {
|
|
546
|
+
if (!channel || typeof channel.id !== 'string' || channel.id.length === 0) {
|
|
547
|
+
addedResults.push({ id: String(channel?.id ?? ''), accepted: false, reason: 'invalid descriptor: missing id' });
|
|
548
|
+
this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, reason: 'missing id (update)' });
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
512
551
|
const key = `${serverId}:${channel.id}`;
|
|
513
552
|
const existing = this.channels.get(key);
|
|
514
|
-
if (existing) {
|
|
515
|
-
|
|
553
|
+
if (!existing) {
|
|
554
|
+
addedResults.push({ id: channel.id, accepted: false, reason: 'not registered — update rejected (§14.5)' });
|
|
555
|
+
this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, channelId: channel.id, reason: 'update for unregistered channel' });
|
|
556
|
+
continue;
|
|
516
557
|
}
|
|
558
|
+
existing.descriptor = channel;
|
|
559
|
+
addedResults.push({ id: channel.id, accepted: true });
|
|
517
560
|
}
|
|
518
561
|
}
|
|
519
562
|
|
|
520
|
-
// Process added channels (register
|
|
563
|
+
// Process added channels (validate per-descriptor, register, reconcile)
|
|
564
|
+
const accepted: typeof params.added = [];
|
|
521
565
|
if (params.added) {
|
|
522
566
|
for (const channel of params.added) {
|
|
567
|
+
if (!channel || typeof channel.id !== 'string' || channel.id.length === 0) {
|
|
568
|
+
addedResults.push({ id: String(channel?.id ?? ''), accepted: false, reason: 'invalid descriptor: missing id' });
|
|
569
|
+
this.emitTraceFn({ type: 'mcpl:channel-descriptor-rejected', serverId, reason: 'missing id' });
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
523
572
|
const key = `${serverId}:${channel.id}`;
|
|
524
573
|
this.channels.set(key, {
|
|
525
574
|
serverId,
|
|
526
575
|
descriptor: channel,
|
|
527
576
|
open: false,
|
|
528
577
|
});
|
|
578
|
+
addedResults.push({ id: channel.id, accepted: true });
|
|
579
|
+
accepted.push(channel);
|
|
529
580
|
}
|
|
530
|
-
await this.reconcileChannels(serverId,
|
|
581
|
+
if (accepted.length > 0) await this.reconcileChannels(serverId, accepted);
|
|
531
582
|
}
|
|
583
|
+
responder?.respond({ results: addedResults });
|
|
532
584
|
|
|
533
585
|
this.emitTraceFn({
|
|
534
586
|
type: 'mcpl:channels-changed',
|
|
@@ -553,13 +605,11 @@ export class ChannelRegistry {
|
|
|
553
605
|
const results: ChannelIncomingMessageResult[] = [];
|
|
554
606
|
|
|
555
607
|
for (const message of params.messages) {
|
|
556
|
-
//
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
//
|
|
560
|
-
|
|
561
|
-
this.defaultPublishMessageId = message.messageId;
|
|
562
|
-
this.defaultPublishThreadId = message.threadId;
|
|
608
|
+
// §14.5 FIRST, before ANY semantic processing: admission against the
|
|
609
|
+
// actually-registered channel precedes tag expansion and content
|
|
610
|
+
// conversion — decoding an unregistered sender's payload (including
|
|
611
|
+
// inline base64) is allocation and parsing work done for a message
|
|
612
|
+
// that must be rejected (Sol, PR #79 re-review blocker 2).
|
|
563
613
|
|
|
564
614
|
// Lazy-register the channel if we've never seen it. A channel can deliver
|
|
565
615
|
// an incoming message before its channels/register (boot enumeration) or
|
|
@@ -572,31 +622,44 @@ export class ChannelRegistry {
|
|
|
572
622
|
// is reachable. The inbound message carries enough to make it publishable,
|
|
573
623
|
// so register it here; a later authoritative channels/register or
|
|
574
624
|
// channels/changed will overwrite this descriptor with the richer one.
|
|
625
|
+
// §14.5: channels/incoming is validated against the ACTUALLY
|
|
626
|
+
// REGISTERED channel. An unknown claimed channelId is rejected
|
|
627
|
+
// per-message with an itemized result — never minted by its first
|
|
628
|
+
// message (PR #79 review blocker 5: lazy-registration let a server
|
|
629
|
+
// self-attest a channel identity without ever passing
|
|
630
|
+
// channels/register authorization). A server whose channel genuinely
|
|
631
|
+
// exists registers it first; discord's DM case goes through
|
|
632
|
+
// ensureChannelRegistered on push/event, which creates a CLOSED
|
|
633
|
+
// routable entry rather than an open self-attested one.
|
|
575
634
|
const incomingKey = `${serverId}:${message.channelId}`;
|
|
576
635
|
if (!this.channels.has(incomingKey)) {
|
|
577
|
-
const channelLabel =
|
|
578
|
-
typeof message.metadata?.channelName === 'string' && message.metadata.channelName
|
|
579
|
-
? (message.metadata.channelName as string)
|
|
580
|
-
: message.channelId;
|
|
581
|
-
this.channels.set(incomingKey, {
|
|
582
|
-
serverId,
|
|
583
|
-
descriptor: {
|
|
584
|
-
id: message.channelId,
|
|
585
|
-
type: serverId,
|
|
586
|
-
label: channelLabel,
|
|
587
|
-
direction: 'bidirectional',
|
|
588
|
-
address: message.threadId ? { threadId: message.threadId } : undefined,
|
|
589
|
-
metadata: { lazyRegistered: true },
|
|
590
|
-
},
|
|
591
|
-
open: true,
|
|
592
|
-
});
|
|
593
636
|
this.emitTraceFn({
|
|
594
|
-
type: 'mcpl:channel-
|
|
637
|
+
type: 'mcpl:channel-incoming-rejected',
|
|
595
638
|
serverId,
|
|
596
639
|
channelId: message.channelId,
|
|
597
|
-
|
|
640
|
+
reason: 'unknown channel — not registered (§14.5)',
|
|
598
641
|
});
|
|
599
|
-
|
|
642
|
+
results.push({
|
|
643
|
+
messageId: message.messageId,
|
|
644
|
+
accepted: false,
|
|
645
|
+
reason: `unknown channel "${message.channelId}" — register it first (§14.5)`,
|
|
646
|
+
});
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ACCEPTED from here down: semantic processing only for admitted
|
|
651
|
+
// messages. §16.3 core-tag closure, then content conversion.
|
|
652
|
+
if (message.tags) message.tags = expandCoreTags(message.tags);
|
|
653
|
+
const convertedContent: ContentBlock[] = message.content.map(convertBlock);
|
|
654
|
+
|
|
655
|
+
// Track default publish channel (most recent ACCEPTED incoming) —
|
|
656
|
+
// deliberately after §14.5 validation: a rejected message from an
|
|
657
|
+
// unregistered channel must not retarget outbound speech (the locus is
|
|
658
|
+
// exactly the authority a self-attested channel would be stealing).
|
|
659
|
+
this.defaultPublishChannel = message.channelId;
|
|
660
|
+
this.defaultPublishMessageId = message.messageId;
|
|
661
|
+
this.defaultPublishThreadId = message.threadId;
|
|
662
|
+
{
|
|
600
663
|
// A server sending channels/incoming is authoritative evidence that
|
|
601
664
|
// the transport is actually open. This repairs transient status only;
|
|
602
665
|
// durable desired state still changes exclusively through lifecycle
|
|
@@ -923,7 +986,7 @@ export class ChannelRegistry {
|
|
|
923
986
|
return this.handleToolThink(input as { content?: string });
|
|
924
987
|
|
|
925
988
|
case 'skip_reply':
|
|
926
|
-
return this.handleToolSkipReply(input as { reason?: string });
|
|
989
|
+
return this.handleToolSkipReply(input as { reason?: string; wake_in_seconds?: number });
|
|
927
990
|
|
|
928
991
|
default:
|
|
929
992
|
return { success: false, error: `Unknown channel tool: ${toolName}`, isError: true };
|
|
@@ -1181,6 +1244,19 @@ export class ChannelRegistry {
|
|
|
1181
1244
|
const server = this.serverRegistry.getServer(serverId);
|
|
1182
1245
|
if (!server) return;
|
|
1183
1246
|
|
|
1247
|
+
// §14.1: channels/open + channels/close require channels.lifecycle.
|
|
1248
|
+
// Skipping reconciliation for an ungranted server is the enforcement —
|
|
1249
|
+
// its channels simply stay in whatever state the server chose, and the
|
|
1250
|
+
// host never directs lifecycle it was not granted authority over.
|
|
1251
|
+
if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
|
|
1252
|
+
this.emitTraceFn({
|
|
1253
|
+
type: 'mcpl:channel-reconcile-skipped',
|
|
1254
|
+
serverId,
|
|
1255
|
+
reason: 'channels.lifecycle not in effective grant (§14.1)',
|
|
1256
|
+
});
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1184
1260
|
// Channels the subscription policy freshly admitted in THIS pass —
|
|
1185
1261
|
// announced to the agent as one batched notice after the loop, so a
|
|
1186
1262
|
// first boot on a new policy doesn't produce one notice per channel.
|
|
@@ -1255,6 +1331,10 @@ export class ChannelRegistry {
|
|
|
1255
1331
|
* (typing timer lifecycle is still managed for when the callback is wired).
|
|
1256
1332
|
*/
|
|
1257
1333
|
private sendTypingNotification(serverId: string, channelId: string): void {
|
|
1334
|
+
// §14.1: channels/typing requires channels.typing in the grant. Silent
|
|
1335
|
+
// skip — a typing indicator is cosmetic and per-7s, so a diagnostic per
|
|
1336
|
+
// tick would be noise.
|
|
1337
|
+
if (!CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.typing')) return;
|
|
1258
1338
|
if (this.sendTypingFn) {
|
|
1259
1339
|
this.sendTypingFn(serverId, channelId, this.typingMetadata.get(channelId));
|
|
1260
1340
|
}
|
|
@@ -1266,6 +1346,13 @@ export class ChannelRegistry {
|
|
|
1266
1346
|
// Private: Channel Lookup
|
|
1267
1347
|
// ==========================================================================
|
|
1268
1348
|
|
|
1349
|
+
/** Descriptors registered by one server — the §14.3 channels/list answer. */
|
|
1350
|
+
descriptorsForServer(serverId: string): ChannelDescriptor[] {
|
|
1351
|
+
return [...this.channels.values()]
|
|
1352
|
+
.filter((e) => e.serverId === serverId)
|
|
1353
|
+
.map((e) => e.descriptor);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1269
1356
|
/** Server id owning a registered channel (by descriptor id), or null. */
|
|
1270
1357
|
getChannelServerId(channelId: string): string | null {
|
|
1271
1358
|
return this.findChannelEntry(channelId)?.serverId ?? null;
|
|
@@ -1370,6 +1457,11 @@ export class ChannelRegistry {
|
|
|
1370
1457
|
if (!server) {
|
|
1371
1458
|
throw new Error(`Server not found: ${entry.serverId}`);
|
|
1372
1459
|
}
|
|
1460
|
+
if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
|
|
1461
|
+
// Desired state is already recorded — intent sticks; reconciliation
|
|
1462
|
+
// will retry if the grant later widens (§6.7 expansion-on-receipt).
|
|
1463
|
+
throw new Error(`channels.lifecycle not in "${entry.serverId}"'s effective grant (§14.1)`);
|
|
1464
|
+
}
|
|
1373
1465
|
const result: ChannelsOpenResult = await server.sendChannelsOpen({
|
|
1374
1466
|
channelId: entry.descriptor.id,
|
|
1375
1467
|
type: entry.descriptor.type,
|
|
@@ -1714,6 +1806,13 @@ export class ChannelRegistry {
|
|
|
1714
1806
|
}
|
|
1715
1807
|
|
|
1716
1808
|
try {
|
|
1809
|
+
if (!CapabilityGrant.of(server).has('channels.lifecycle')) {
|
|
1810
|
+
return {
|
|
1811
|
+
success: false,
|
|
1812
|
+
error: `channels.lifecycle not in "${entry.serverId}"'s effective grant (§14.1)`,
|
|
1813
|
+
isError: true,
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1717
1816
|
await server.sendChannelsClose({ channelId: input.channelId });
|
|
1718
1817
|
entry.open = false;
|
|
1719
1818
|
return {
|
|
@@ -1759,6 +1858,8 @@ export class ChannelRegistry {
|
|
|
1759
1858
|
const server = this.serverRegistry.getServer(entry.serverId);
|
|
1760
1859
|
if (!server) {
|
|
1761
1860
|
acknowledgmentError = `Server not found: ${entry.serverId}`;
|
|
1861
|
+
} else if (!CapabilityGrant.of(server).has('channels.acknowledge')) {
|
|
1862
|
+
acknowledgmentError = `channels.acknowledge not in "${entry.serverId}"'s effective grant (§14.1)`;
|
|
1762
1863
|
} else {
|
|
1763
1864
|
try {
|
|
1764
1865
|
const result = await server.sendChannelsAcknowledge({
|
|
@@ -1827,7 +1928,14 @@ export class ChannelRegistry {
|
|
|
1827
1928
|
* tool, so any trailing prose this turn is NOT posted. Replaces the old
|
|
1828
1929
|
* overloaded use of `think` for staying silent.
|
|
1829
1930
|
*/
|
|
1830
|
-
private handleToolSkipReply(input: { reason?: string }): ToolResult {
|
|
1931
|
+
private handleToolSkipReply(input: { reason?: string; wake_in_seconds?: number }): ToolResult {
|
|
1932
|
+
// The self-wake itself is armed by the framework (which owns the
|
|
1933
|
+
// EventGate) BEFORE this dispatch; when the gate is absent the framework
|
|
1934
|
+
// strips the field so this confirmation stays truthful.
|
|
1935
|
+
const wakeSecs = Number(input.wake_in_seconds);
|
|
1936
|
+
const selfWake = Number.isFinite(wakeSecs) && wakeSecs > 0
|
|
1937
|
+
? Math.max(1, Math.min(3600, Math.floor(wakeSecs)))
|
|
1938
|
+
: undefined;
|
|
1831
1939
|
return {
|
|
1832
1940
|
success: true,
|
|
1833
1941
|
// The note says "ended the turn" — make it TRUE. Without endTurn the
|
|
@@ -1841,7 +1949,9 @@ export class ChannelRegistry {
|
|
|
1841
1949
|
// on Fable: ~60% of skip_reply result bytes were verbatim input echo).
|
|
1842
1950
|
data: {
|
|
1843
1951
|
skipped: true,
|
|
1844
|
-
note:
|
|
1952
|
+
note: selfWake !== undefined
|
|
1953
|
+
? `Turn ended; nothing sent. Self-wake in ~${selfWake}s unless something wakes you first.`
|
|
1954
|
+
: 'Turn ended; nothing sent.',
|
|
1845
1955
|
},
|
|
1846
1956
|
};
|
|
1847
1957
|
}
|
|
@@ -1907,7 +2017,12 @@ export class ChannelRegistry {
|
|
|
1907
2017
|
const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId);
|
|
1908
2018
|
if (matches.length !== 1) return null;
|
|
1909
2019
|
const server = this.serverRegistry.getServer(matches[0]!.serverId);
|
|
1910
|
-
|
|
2020
|
+
// §5.4: the GRANT gates streaming, not the raw advertisement. The old
|
|
2021
|
+
// `capabilities?.channels?.streaming` check was doubly wrong: undefined
|
|
2022
|
+
// for the boolean `channels: true` shape (masking discord-mcpl's latent
|
|
2023
|
+
// double-post, AUDIT-001), and pre-policy it would have sent before the
|
|
2024
|
+
// server was told anything was granted.
|
|
2025
|
+
if (!CapabilityGrant.of(server).has('channels.streaming')) return null;
|
|
1911
2026
|
return server;
|
|
1912
2027
|
}
|
|
1913
2028
|
|
|
@@ -1999,6 +2114,12 @@ export class ChannelRegistry {
|
|
|
1999
2114
|
if (!server) {
|
|
2000
2115
|
return fail(channelId, `server "${entry.serverId}" not found`);
|
|
2001
2116
|
}
|
|
2117
|
+
// §14.1: channels/publish requires channels.publish in the grant. The
|
|
2118
|
+
// host not sending is the enforcement — a send to an ungranted server
|
|
2119
|
+
// would have it act on authority it was never told it has.
|
|
2120
|
+
if (!CapabilityGrant.of(server).has('channels.publish')) {
|
|
2121
|
+
return fail(channelId, `channels.publish not in "${entry.serverId}"'s effective grant (§14.1)`);
|
|
2122
|
+
}
|
|
2002
2123
|
|
|
2003
2124
|
const publishParams: ChannelsPublishParams = {
|
|
2004
2125
|
conversationId,
|
|
@@ -2075,6 +2196,13 @@ export class ChannelRegistry {
|
|
|
2075
2196
|
}
|
|
2076
2197
|
|
|
2077
2198
|
try {
|
|
2199
|
+
if (!CapabilityGrant.of(server).has('channels.publish')) {
|
|
2200
|
+
return {
|
|
2201
|
+
success: false,
|
|
2202
|
+
error: `channels.publish not in "${server.id}"'s effective grant (§14.1)`,
|
|
2203
|
+
isError: true,
|
|
2204
|
+
};
|
|
2205
|
+
}
|
|
2078
2206
|
const publishParams: ChannelsPublishParams = {
|
|
2079
2207
|
conversationId: '', // Framework will fill this when wired
|
|
2080
2208
|
channelId,
|
|
@@ -355,17 +355,37 @@ export class CheckpointManager {
|
|
|
355
355
|
}
|
|
356
356
|
|
|
357
357
|
/**
|
|
358
|
-
*
|
|
359
|
-
*
|
|
358
|
+
* Resolve a server-managed stateful feature set for a server.
|
|
359
|
+
*
|
|
360
|
+
* An explicit feature set is returned only when it is registered and
|
|
361
|
+
* server-managed. Without one, the fallback is safe only when exactly one
|
|
362
|
+
* server-managed stateful set exists. Returns null for no match or ambiguity.
|
|
360
363
|
*/
|
|
361
|
-
getStatefulFeatureSet(serverId: string): string | null {
|
|
364
|
+
getStatefulFeatureSet(serverId: string, featureSet?: string): string | null {
|
|
362
365
|
const prefix = `${serverId}:`;
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
366
|
+
|
|
367
|
+
if (featureSet !== undefined) {
|
|
368
|
+
const tree = this.trees.get(this.key(serverId, featureSet));
|
|
369
|
+
return tree && !tree.hostState ? featureSet : null;
|
|
367
370
|
}
|
|
368
|
-
|
|
371
|
+
|
|
372
|
+
let match: string | null = null;
|
|
373
|
+
for (const [key, tree] of this.trees) {
|
|
374
|
+
if (!key.startsWith(prefix) || tree.hostState) continue;
|
|
375
|
+
if (match !== null) return null;
|
|
376
|
+
match = key.slice(prefix.length);
|
|
377
|
+
}
|
|
378
|
+
return match;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Whether an untagged tool cannot safely select a server-managed set. */
|
|
382
|
+
hasAmbiguousServerManagedFeatureSets(serverId: string): boolean {
|
|
383
|
+
const prefix = `${serverId}:`;
|
|
384
|
+
let count = 0;
|
|
385
|
+
for (const [key, tree] of this.trees) {
|
|
386
|
+
if (key.startsWith(prefix) && !tree.hostState && ++count > 1) return true;
|
|
387
|
+
}
|
|
388
|
+
return false;
|
|
369
389
|
}
|
|
370
390
|
|
|
371
391
|
/**
|
package/src/mcpl/errors.ts
CHANGED
|
@@ -14,6 +14,13 @@ import type { JsonRpcError } from './types.js';
|
|
|
14
14
|
/** Message used a disabled feature set. */
|
|
15
15
|
export const FEATURE_SET_NOT_ENABLED = -32001;
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Method requires a capability the host has masked for this server
|
|
19
|
+
* (McplServerConfig.enabledCapabilities/disabledCapabilities). Host-side
|
|
20
|
+
* extension code, pending a spec assignment.
|
|
21
|
+
*/
|
|
22
|
+
export const CAPABILITY_DISABLED = -32002;
|
|
23
|
+
|
|
17
24
|
/** Message used an undeclared feature set. */
|
|
18
25
|
export const UNKNOWN_FEATURE_SET = -32003;
|
|
19
26
|
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
FEATURE_SET_NOT_ENABLED,
|
|
19
19
|
UNKNOWN_FEATURE_SET,
|
|
20
20
|
} from './errors.js';
|
|
21
|
+
import { isKnownCapabilityPath } from './capability-grant.js';
|
|
21
22
|
|
|
22
23
|
// ============================================================================
|
|
23
24
|
// Error Class
|
|
@@ -143,7 +144,16 @@ export class FeatureSetManager {
|
|
|
143
144
|
initializeServer(
|
|
144
145
|
serverId: string,
|
|
145
146
|
capabilities: McplCapabilities,
|
|
146
|
-
config?: { enabledFeatureSets?: string[]; disabledFeatureSets?: string[] }
|
|
147
|
+
config?: { enabledFeatureSets?: string[]; disabledFeatureSets?: string[] },
|
|
148
|
+
/**
|
|
149
|
+
* The effective capability grant (§5.4). When provided, §6.4's
|
|
150
|
+
* FAIL-CLOSED derivation runs: a feature set whose `uses` is absent,
|
|
151
|
+
* empty, or contains an unrecognized value is disabled with reason
|
|
152
|
+
* `invalid_uses`; one whose `uses` names an ungranted capability is
|
|
153
|
+
* disabled with the missing paths recorded. Derivation overrides config
|
|
154
|
+
* enablement — config can narrow the derivable set, never widen past it.
|
|
155
|
+
*/
|
|
156
|
+
grant?: import('./capability-grant.js').CapabilityGrant,
|
|
147
157
|
): FeatureSetsUpdateParams {
|
|
148
158
|
// Cross-package compat: agent-framework's McplCapabilities types
|
|
149
159
|
// featureSets as Record<string, FeatureSetDeclaration>, but mcpl-core-ts
|
|
@@ -165,11 +175,16 @@ export class FeatureSetManager {
|
|
|
165
175
|
enabled: new Set<string>(),
|
|
166
176
|
};
|
|
167
177
|
|
|
168
|
-
//
|
|
169
|
-
|
|
178
|
+
// §5.3 as pinned (mcpl e869744): ABSENT enabledFeatureSets constrains
|
|
179
|
+
// nothing — every declared set is a candidate and §6.4 derivation
|
|
180
|
+
// governs. PRESENT is an allowlist ([] = deny-all selection). The old
|
|
181
|
+
// reading (unmentioned defaults disabled) reversed the pin (PR #79
|
|
182
|
+
// review blocker 7). FLEET BEHAVIOR CHANGE: configs that omitted
|
|
183
|
+
// enabledFeatureSets now enable every derivable declared set.
|
|
170
184
|
const disablePatterns = config?.disabledFeatureSets ?? [];
|
|
171
|
-
|
|
172
|
-
|
|
185
|
+
const toEnable = config?.enabledFeatureSets === undefined
|
|
186
|
+
? Object.keys(declared)
|
|
187
|
+
: resolvePatterns(config.enabledFeatureSets, declared);
|
|
173
188
|
const toDisable = resolvePatterns(disablePatterns, declared);
|
|
174
189
|
|
|
175
190
|
// Enable matching sets
|
|
@@ -182,6 +197,36 @@ export class FeatureSetManager {
|
|
|
182
197
|
state.enabled.delete(name);
|
|
183
198
|
}
|
|
184
199
|
|
|
200
|
+
// §6.4 fail-closed derivation against the grant. Runs AFTER config
|
|
201
|
+
// resolution so it can only remove: nothing a declaration or a config
|
|
202
|
+
// says can supply a capability the grant lacks.
|
|
203
|
+
if (grant) {
|
|
204
|
+
for (const [name, decl] of Object.entries(declared)) {
|
|
205
|
+
const uses = (decl as { uses?: unknown }).uses;
|
|
206
|
+
if (!Array.isArray(uses) || uses.length === 0) {
|
|
207
|
+
if (state.enabled.delete(name) || true) {
|
|
208
|
+
console.error(`[mcpl] ${serverId}/${name} disabled: invalid_uses (§6.4 — uses absent or empty)`);
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const unknown = uses.filter((u) => typeof u !== 'string' || !isKnownCapabilityPath(u));
|
|
213
|
+
if (unknown.length > 0) {
|
|
214
|
+
state.enabled.delete(name);
|
|
215
|
+
console.error(
|
|
216
|
+
`[mcpl] ${serverId}/${name} disabled: invalid_uses (§6.4 — unrecognized: ${unknown.join(', ')})`,
|
|
217
|
+
);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const missing = (uses as string[]).filter((u) => !grant.has(u));
|
|
221
|
+
if (missing.length > 0) {
|
|
222
|
+
state.enabled.delete(name);
|
|
223
|
+
console.error(
|
|
224
|
+
`[mcpl] ${serverId}/${name} disabled: missing capabilities ${missing.join(', ')} (§6.4)`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
185
230
|
this.servers.set(serverId, state);
|
|
186
231
|
|
|
187
232
|
// Build the update params to send to the server
|