@animalabs/agent-framework 0.11.0 → 0.13.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 +11 -1
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +60 -4
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +72 -1
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +873 -123
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +7 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +15 -1
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/kv-unified-wire.d.ts +16 -0
- package/dist/src/kv-unified-wire.d.ts.map +1 -0
- package/dist/src/kv-unified-wire.js +2 -0
- package/dist/src/kv-unified-wire.js.map +1 -0
- package/dist/src/mcpl/channel-registry.d.ts +84 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +188 -5
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.js +28 -2
- package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +6 -1
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +29 -2
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +59 -0
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -0
- package/dist/src/mcpl/reference-fetcher.js +240 -0
- package/dist/src/mcpl/reference-fetcher.js.map +1 -0
- package/dist/src/mcpl/references.d.ts +125 -0
- package/dist/src/mcpl/references.d.ts.map +1 -0
- package/dist/src/mcpl/references.js +262 -0
- package/dist/src/mcpl/references.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +32 -0
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/modules/index.d.ts +2 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +125 -2
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +354 -58
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +7 -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/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +14 -0
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/tool-wrapper-prose-guard.d.ts +8 -0
- package/dist/src/tool-wrapper-prose-guard.d.ts.map +1 -0
- package/dist/src/tool-wrapper-prose-guard.js +37 -0
- package/dist/src/tool-wrapper-prose-guard.js.map +1 -0
- package/dist/src/tune-out/coordinator.d.ts +144 -0
- package/dist/src/tune-out/coordinator.d.ts.map +1 -0
- package/dist/src/tune-out/coordinator.js +444 -0
- package/dist/src/tune-out/coordinator.js.map +1 -0
- package/dist/src/tune-out/tools.d.ts +43 -0
- package/dist/src/tune-out/tools.d.ts.map +1 -0
- package/dist/src/tune-out/tools.js +93 -0
- package/dist/src/tune-out/tools.js.map +1 -0
- package/dist/src/types/agent.d.ts +15 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +6 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/usage-accounting.d.ts +5 -0
- package/dist/src/usage-accounting.d.ts.map +1 -0
- package/dist/src/usage-accounting.js +11 -0
- package/dist/src/usage-accounting.js.map +1 -0
- package/dist/test/channel-incoming-targeting.test.d.ts +2 -0
- package/dist/test/channel-incoming-targeting.test.d.ts.map +1 -0
- package/dist/test/channel-incoming-targeting.test.js +81 -0
- package/dist/test/channel-incoming-targeting.test.js.map +1 -0
- package/dist/test/conversation-routing.test.js +39 -0
- package/dist/test/conversation-routing.test.js.map +1 -1
- package/dist/test/kv-unified-wiring.test.d.ts +2 -0
- package/dist/test/kv-unified-wiring.test.d.ts.map +1 -0
- package/dist/test/kv-unified-wiring.test.js +56 -0
- package/dist/test/kv-unified-wiring.test.js.map +1 -0
- package/dist/test/mcpl-push-convert.test.d.ts +2 -0
- package/dist/test/mcpl-push-convert.test.d.ts.map +1 -0
- package/dist/test/mcpl-push-convert.test.js +42 -0
- package/dist/test/mcpl-push-convert.test.js.map +1 -0
- package/dist/test/mcpl-reference-fetcher.test.d.ts +2 -0
- package/dist/test/mcpl-reference-fetcher.test.d.ts.map +1 -0
- package/dist/test/mcpl-reference-fetcher.test.js +199 -0
- package/dist/test/mcpl-reference-fetcher.test.js.map +1 -0
- package/dist/test/per-agent-delivery.test.d.ts +2 -0
- package/dist/test/per-agent-delivery.test.d.ts.map +1 -0
- package/dist/test/per-agent-delivery.test.js +108 -0
- package/dist/test/per-agent-delivery.test.js.map +1 -0
- package/dist/test/present-while-acting.test.js +64 -0
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/tool-result-history.test.js +78 -0
- package/dist/test/tool-result-history.test.js.map +1 -1
- package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts +2 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts.map +1 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.js +485 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -0
- package/dist/test/tool-wrapper-prose-guard.test.d.ts +2 -0
- package/dist/test/tool-wrapper-prose-guard.test.d.ts.map +1 -0
- package/dist/test/tool-wrapper-prose-guard.test.js +34 -0
- package/dist/test/tool-wrapper-prose-guard.test.js.map +1 -0
- package/dist/test/tune-out-e2e.test.d.ts +2 -0
- package/dist/test/tune-out-e2e.test.d.ts.map +1 -0
- package/dist/test/tune-out-e2e.test.js +142 -0
- package/dist/test/tune-out-e2e.test.js.map +1 -0
- package/dist/test/tune-out-gate-composition.test.d.ts +2 -0
- package/dist/test/tune-out-gate-composition.test.d.ts.map +1 -0
- package/dist/test/tune-out-gate-composition.test.js +221 -0
- package/dist/test/tune-out-gate-composition.test.js.map +1 -0
- package/dist/test/tune-out-lifecycle.test.d.ts +2 -0
- package/dist/test/tune-out-lifecycle.test.d.ts.map +1 -0
- package/dist/test/tune-out-lifecycle.test.js +121 -0
- package/dist/test/tune-out-lifecycle.test.js.map +1 -0
- package/dist/test/turn-trigger-provenance.test.d.ts +2 -0
- package/dist/test/turn-trigger-provenance.test.d.ts.map +1 -0
- package/dist/test/turn-trigger-provenance.test.js +85 -0
- package/dist/test/turn-trigger-provenance.test.js.map +1 -0
- package/dist/test/usage-accounting.test.d.ts +2 -0
- package/dist/test/usage-accounting.test.d.ts.map +1 -0
- package/dist/test/usage-accounting.test.js +13 -0
- package/dist/test/usage-accounting.test.js.map +1 -0
- package/dist/test/workspace-branch-continuation.test.d.ts +15 -0
- package/dist/test/workspace-branch-continuation.test.d.ts.map +1 -0
- package/dist/test/workspace-branch-continuation.test.js +183 -0
- package/dist/test/workspace-branch-continuation.test.js.map +1 -0
- package/dist/test/workspace-read-file-from-disk.test.d.ts +17 -0
- package/dist/test/workspace-read-file-from-disk.test.d.ts.map +1 -0
- package/dist/test/workspace-read-file-from-disk.test.js +174 -0
- package/dist/test/workspace-read-file-from-disk.test.js.map +1 -0
- package/dist/test/workspace-read-image.test.js +8 -0
- package/dist/test/workspace-read-image.test.js.map +1 -1
- package/dist/test/workspace-restore-order.test.js +1 -1
- package/dist/test/workspace-restore-order.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +77 -4
- package/src/framework.ts +953 -128
- package/src/gate/event-gate.ts +28 -3
- package/src/kv-unified-wire.ts +12 -0
- package/src/mcpl/channel-registry.ts +264 -8
- package/src/mcpl/hook-orchestrator.ts +29 -2
- package/src/mcpl/push-handler.ts +30 -2
- package/src/mcpl/reference-fetcher.ts +240 -0
- package/src/mcpl/references.ts +292 -0
- package/src/mcpl/types.ts +29 -0
- package/src/modules/index.ts +2 -1
- package/src/modules/workspace/index.ts +425 -66
- package/src/modules/workspace/types.ts +7 -0
- package/src/tool-result-history.ts +11 -0
- package/src/tool-wrapper-prose-guard.ts +38 -0
- package/src/tune-out/coordinator.ts +548 -0
- package/src/tune-out/tools.ts +129 -0
- package/src/types/agent.ts +16 -0
- package/src/types/framework.ts +7 -0
- package/src/usage-accounting.ts +8 -0
package/src/gate/event-gate.ts
CHANGED
|
@@ -515,7 +515,13 @@ export class EventGate {
|
|
|
515
515
|
|
|
516
516
|
// Dependency-injected callbacks
|
|
517
517
|
private emitTrace: (event: TraceEventLike) => void;
|
|
518
|
-
private addMessageFn: (
|
|
518
|
+
private addMessageFn: (
|
|
519
|
+
participant: string,
|
|
520
|
+
content: Array<{ type: 'text'; text: string }>,
|
|
521
|
+
metadata?: Record<string, unknown>,
|
|
522
|
+
/** Registry agent to deliver into; absent = primary (historical). */
|
|
523
|
+
forAgent?: string,
|
|
524
|
+
) => unknown;
|
|
519
525
|
private requestInferenceFn: (agentName: string, reason: string, source: string) => void;
|
|
520
526
|
private getAgentNamesFn: () => string[];
|
|
521
527
|
/** Clock injection — keeps the new rate_limit / passive_sample paths
|
|
@@ -527,7 +533,12 @@ export class EventGate {
|
|
|
527
533
|
initialConfig?: GateConfig;
|
|
528
534
|
privilegedUsersPath?: string;
|
|
529
535
|
emitTrace: (event: TraceEventLike) => void;
|
|
530
|
-
addMessage: (
|
|
536
|
+
addMessage: (
|
|
537
|
+
participant: string,
|
|
538
|
+
content: Array<{ type: 'text'; text: string }>,
|
|
539
|
+
metadata?: Record<string, unknown>,
|
|
540
|
+
forAgent?: string,
|
|
541
|
+
) => unknown;
|
|
531
542
|
requestInference: (agentName: string, reason: string, source: string) => void;
|
|
532
543
|
getAgentNames: () => string[];
|
|
533
544
|
/** Optional clock — defaults to Date.now. Tests inject for deterministic time. */
|
|
@@ -1582,10 +1593,13 @@ export class EventGate {
|
|
|
1582
1593
|
// before the inference request so it rides the wake turn (addMessage's
|
|
1583
1594
|
// turn-alive guard defers it to that turn's start if needed).
|
|
1584
1595
|
const nowIso = new Date(this.now()).toISOString().slice(0, 19) + 'Z';
|
|
1596
|
+
// Deliver the notice into the WAKING agent's window — a self-wake
|
|
1597
|
+
// armed by a non-primary agent (e.g. a subconscious cadence) must not
|
|
1598
|
+
// narrate itself into the primary's context.
|
|
1585
1599
|
this.addMessageFn('user', [{
|
|
1586
1600
|
type: 'text',
|
|
1587
1601
|
text: `[self-wake] your ${source} timer (${Math.round(ms / 1000)}s) elapsed — now ${nowIso}`,
|
|
1588
|
-
}], { source: 'gate:self-wake' });
|
|
1602
|
+
}], { source: 'gate:self-wake' }, agentName);
|
|
1589
1603
|
this.requestInferenceFn(
|
|
1590
1604
|
agentName,
|
|
1591
1605
|
`self-scheduled wake (${source}, ${Math.round(ms / 1000)}s)`,
|
|
@@ -1659,6 +1673,17 @@ export class EventGate {
|
|
|
1659
1673
|
}
|
|
1660
1674
|
|
|
1661
1675
|
/** Load the privileged-users file (bare array or { userIds: [...] }). */
|
|
1676
|
+
/**
|
|
1677
|
+
* Whether an author id is on the privileged-users list (hot-reloaded).
|
|
1678
|
+
* Shared by sleep bypass and tune-out wake classification (#77: wakes are
|
|
1679
|
+
* "mentions, gate-privileged authors, etc.").
|
|
1680
|
+
*/
|
|
1681
|
+
isPrivilegedUser(authorId: string | null | undefined): boolean {
|
|
1682
|
+
if (!authorId) return false;
|
|
1683
|
+
this.loadPrivileged();
|
|
1684
|
+
return this.privilegedUserIds.has(authorId);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1662
1687
|
private loadPrivileged(): void {
|
|
1663
1688
|
if (!this.privilegedUsersPath) return;
|
|
1664
1689
|
try {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Structural copy of membrane's cache-wire receipt contract.
|
|
2
|
+
* Kept local so agent-framework remains source-compatible with the previous
|
|
3
|
+
* membrane release while the stacked membrane PR lands. */
|
|
4
|
+
export interface CacheWireReceipt {
|
|
5
|
+
requestHash: string;
|
|
6
|
+
markers: Array<{ ordinal: number; prefixHash: string; estimatedOffset: number }>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface KvUnifiedRequestHooks {
|
|
10
|
+
cacheMarkers?: 'membrane-system' | 'cm-owned';
|
|
11
|
+
onCacheWireReceipt?: (receipt: CacheWireReceipt) => void;
|
|
12
|
+
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { ContentBlock } from '@animalabs/membrane';
|
|
17
|
+
import { INLINE_WITHHELD_TEXT, isInlineContradiction, referenceStubOrNull } from './references.js';
|
|
17
18
|
import type { JsStore } from '@animalabs/chronicle';
|
|
18
19
|
|
|
19
20
|
import type {
|
|
@@ -44,14 +45,53 @@ import { CapabilityGrant } from './capability-grant.js';
|
|
|
44
45
|
const TYPING_INTERVAL_MS = 7_000;
|
|
45
46
|
const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle';
|
|
46
47
|
|
|
47
|
-
type DesiredChannelState = 'open' | 'closed';
|
|
48
|
+
type DesiredChannelState = 'open' | 'closed' | 'tuned-out';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parameters of an active tune-out (issue #77), carried on the
|
|
52
|
+
* 'desired-state' record entering the tuned-out state and projected into
|
|
53
|
+
* `desiredStates`. A tuned-out channel stays OPEN at the transport (traffic
|
|
54
|
+
* must keep arriving for the subconscious); the divert-don't-wake behavior
|
|
55
|
+
* is applied downstream at ingestion.
|
|
56
|
+
*/
|
|
57
|
+
export interface TuneOutParams {
|
|
58
|
+
/** Identity of this tune-out epoch. Stamped into diverted messages
|
|
59
|
+
* (`metadata.tuneOut = { epochId }`) for permanent main-view exclusion
|
|
60
|
+
* and per-epoch audit; also keys durable wake counting. */
|
|
61
|
+
epochId: string;
|
|
62
|
+
/** Subconscious summary cadence, in seconds. */
|
|
63
|
+
cadenceSeconds: number;
|
|
64
|
+
/** Maximum messages dumped raw at cancel; above the cap the subconscious
|
|
65
|
+
* curates a digest and `fetch_history` covers the rest. */
|
|
66
|
+
backlogCap: number;
|
|
67
|
+
/** Wake invocations before the tune-out auto-cancels. */
|
|
68
|
+
maxWakes: number;
|
|
69
|
+
/** Chronicle sequence when the tune-out began — window anchor + audit bound. */
|
|
70
|
+
startedAtSequence: number;
|
|
71
|
+
/** Absolute wall-clock deadline (epoch ms). When set, the tune-out
|
|
72
|
+
* auto-cancels at this time via the standard cancel flow ("duration
|
|
73
|
+
* elapsed") — the agent's self-binding attention budget (#77 "for a
|
|
74
|
+
* period chosen by the agent"). Unset = until cancelled. */
|
|
75
|
+
expiresAtMs?: number;
|
|
76
|
+
}
|
|
48
77
|
|
|
49
78
|
interface ChannelLifecycleEvent {
|
|
50
|
-
kind:
|
|
79
|
+
kind:
|
|
80
|
+
| 'desired-state'
|
|
81
|
+
| 'legacy-policy-migrated'
|
|
82
|
+
| 'invitation-declined'
|
|
83
|
+
| 'tune-out-wake';
|
|
51
84
|
serverId: string;
|
|
52
85
|
timestamp: string;
|
|
53
86
|
channelId?: string;
|
|
54
87
|
desired?: DesiredChannelState;
|
|
88
|
+
/** Present when desired === 'tuned-out'. */
|
|
89
|
+
tuneOut?: TuneOutParams;
|
|
90
|
+
/** kind 'tune-out-wake': durable running wake count for an epoch.
|
|
91
|
+
* Lives in the lifecycle log (not gate stats) because gate runtime
|
|
92
|
+
* state dies with the process and max-wakes must not reset on restart. */
|
|
93
|
+
epochId?: string;
|
|
94
|
+
wakeCount?: number;
|
|
55
95
|
source?: string;
|
|
56
96
|
messageId?: string;
|
|
57
97
|
acknowledgment?: string;
|
|
@@ -124,6 +164,16 @@ function convertBlock(block: McplContentBlock): ContentBlock {
|
|
|
124
164
|
return { type: 'text', text: block.text };
|
|
125
165
|
|
|
126
166
|
case 'image':
|
|
167
|
+
if (isInlineContradiction(block)) {
|
|
168
|
+
// RFC-005 vector 2: inline data claiming bulk disposition — fail
|
|
169
|
+
// closed, withhold the data (checked BEFORE the data branch).
|
|
170
|
+
return { type: 'text', text: INLINE_WITHHELD_TEXT };
|
|
171
|
+
}
|
|
172
|
+
if (block.uri && block.disposition) {
|
|
173
|
+
// RFC-005 uri-form media with a disposition claim: stub, do not
|
|
174
|
+
// hand the URI to the provider or inline it.
|
|
175
|
+
return { type: 'text', text: referenceStubOrNull(block, 'attachment on channel message') ?? '[reference]' };
|
|
176
|
+
}
|
|
127
177
|
if (block.data && block.mimeType) {
|
|
128
178
|
return {
|
|
129
179
|
type: 'image',
|
|
@@ -139,6 +189,16 @@ function convertBlock(block: McplContentBlock): ContentBlock {
|
|
|
139
189
|
return { type: 'text', text: '[Image: no data]' };
|
|
140
190
|
|
|
141
191
|
case 'audio':
|
|
192
|
+
if (isInlineContradiction(block)) {
|
|
193
|
+
// RFC-005 vector 2: inline data claiming bulk disposition — fail
|
|
194
|
+
// closed, withhold the data (checked BEFORE the data branch).
|
|
195
|
+
return { type: 'text', text: INLINE_WITHHELD_TEXT };
|
|
196
|
+
}
|
|
197
|
+
if (block.uri && block.disposition) {
|
|
198
|
+
// RFC-005 uri-form media with a disposition claim: stub, do not
|
|
199
|
+
// hand the URI to the provider or inline it.
|
|
200
|
+
return { type: 'text', text: referenceStubOrNull(block, 'attachment on channel message') ?? '[reference]' };
|
|
201
|
+
}
|
|
142
202
|
if (block.data && block.mimeType) {
|
|
143
203
|
return {
|
|
144
204
|
type: 'audio',
|
|
@@ -148,7 +208,14 @@ function convertBlock(block: McplContentBlock): ContentBlock {
|
|
|
148
208
|
return { type: 'text', text: '[Audio: no data]' };
|
|
149
209
|
|
|
150
210
|
case 'resource':
|
|
151
|
-
|
|
211
|
+
// RFC-005: reference blocks become bounded stubs — never raw URIs
|
|
212
|
+
// (a signed URL is a bearer credential that looks like a location).
|
|
213
|
+
return { type: 'text', text: referenceStubOrNull(block, 'attachment on channel message') ?? '[reference]' };
|
|
214
|
+
|
|
215
|
+
default:
|
|
216
|
+
// Unknown wire block types previously fell off the exhaustive switch
|
|
217
|
+
// and propagated `undefined` into ContentBlock[]. Fail visibly.
|
|
218
|
+
return { type: 'text', text: `[unrecognized content block: ${(block as { type?: string }).type ?? 'untyped'}]` };
|
|
152
219
|
}
|
|
153
220
|
}
|
|
154
221
|
|
|
@@ -410,7 +477,15 @@ export class ChannelRegistry {
|
|
|
410
477
|
/** Chronicle-projected desired lifecycle state, keyed by server + channel.
|
|
411
478
|
* Provenance is kept so reconcile can tell a pure default (nobody ever
|
|
412
479
|
* decided) from a real decision (agent-tool, invitation-declined, …). */
|
|
413
|
-
private desiredStates = new Map<string, {
|
|
480
|
+
private desiredStates = new Map<string, {
|
|
481
|
+
state: DesiredChannelState;
|
|
482
|
+
source: string;
|
|
483
|
+
/** Present iff state === 'tuned-out'. */
|
|
484
|
+
tuneOut?: TuneOutParams;
|
|
485
|
+
/** Durable wake count for the active tune-out epoch (replayed from
|
|
486
|
+
* 'tune-out-wake' lifecycle records; see recordTuneOutWake). */
|
|
487
|
+
wakeCount?: number;
|
|
488
|
+
}>();
|
|
414
489
|
|
|
415
490
|
/** One-time migration inputs from the retired recipe auto-open policy. */
|
|
416
491
|
private legacyPolicies = new Map<string, 'auto' | 'manual' | string[]>();
|
|
@@ -1118,12 +1193,31 @@ export class ChannelRegistry {
|
|
|
1118
1193
|
if (
|
|
1119
1194
|
event.kind === 'desired-state' &&
|
|
1120
1195
|
typeof event.channelId === 'string' &&
|
|
1121
|
-
(event.desired === 'open' || event.desired === 'closed'
|
|
1196
|
+
(event.desired === 'open' || event.desired === 'closed' ||
|
|
1197
|
+
(event.desired === 'tuned-out' && event.tuneOut))
|
|
1122
1198
|
) {
|
|
1123
1199
|
this.desiredStates.set(
|
|
1124
1200
|
this.lifecycleKey(event.serverId, event.channelId),
|
|
1125
|
-
{
|
|
1201
|
+
{
|
|
1202
|
+
state: event.desired,
|
|
1203
|
+
source: typeof event.source === "string" ? event.source : "unknown",
|
|
1204
|
+
tuneOut: event.desired === 'tuned-out' ? event.tuneOut : undefined,
|
|
1205
|
+
wakeCount: 0,
|
|
1206
|
+
},
|
|
1126
1207
|
);
|
|
1208
|
+
} else if (
|
|
1209
|
+
event.kind === 'tune-out-wake' &&
|
|
1210
|
+
typeof event.channelId === 'string' &&
|
|
1211
|
+
typeof event.wakeCount === 'number'
|
|
1212
|
+
) {
|
|
1213
|
+
// Fold durable wake counts into the projection — but only while the
|
|
1214
|
+
// epoch that recorded them is still the active desired state
|
|
1215
|
+
// (last-record-wins semantics, same as desired-state itself).
|
|
1216
|
+
const key = this.lifecycleKey(event.serverId, event.channelId);
|
|
1217
|
+
const current = this.desiredStates.get(key);
|
|
1218
|
+
if (current?.state === 'tuned-out' && current.tuneOut?.epochId === event.epochId) {
|
|
1219
|
+
current.wakeCount = event.wakeCount;
|
|
1220
|
+
}
|
|
1127
1221
|
} else if (event.kind === 'legacy-policy-migrated') {
|
|
1128
1222
|
this.migratedLegacyPolicies.add(event.serverId);
|
|
1129
1223
|
}
|
|
@@ -1153,6 +1247,147 @@ export class ChannelRegistry {
|
|
|
1153
1247
|
});
|
|
1154
1248
|
}
|
|
1155
1249
|
|
|
1250
|
+
// ==========================================================================
|
|
1251
|
+
// Tune-out state (issue #77) — durable in the lifecycle log
|
|
1252
|
+
// ==========================================================================
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* Enter (or re-enter with fresh params) the tuned-out state for a channel.
|
|
1256
|
+
* Re-entering under a new epochId replaces the active epoch; the previous
|
|
1257
|
+
* epoch's stamped messages stay excluded (stamps are permanent) and its
|
|
1258
|
+
* wake count is superseded. Transport stays open (see reconcile).
|
|
1259
|
+
*/
|
|
1260
|
+
enterTuneOut(
|
|
1261
|
+
serverId: string,
|
|
1262
|
+
channelId: string,
|
|
1263
|
+
params: TuneOutParams,
|
|
1264
|
+
source: string,
|
|
1265
|
+
): void {
|
|
1266
|
+
const key = this.lifecycleKey(serverId, channelId);
|
|
1267
|
+
this.desiredStates.set(key, {
|
|
1268
|
+
state: 'tuned-out',
|
|
1269
|
+
source,
|
|
1270
|
+
tuneOut: params,
|
|
1271
|
+
wakeCount: 0,
|
|
1272
|
+
});
|
|
1273
|
+
this.appendLifecycleEvent({
|
|
1274
|
+
kind: 'desired-state',
|
|
1275
|
+
serverId,
|
|
1276
|
+
channelId,
|
|
1277
|
+
desired: 'tuned-out',
|
|
1278
|
+
tuneOut: params,
|
|
1279
|
+
source,
|
|
1280
|
+
timestamp: new Date().toISOString(),
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* End the active tune-out, returning the channel to `nextState`.
|
|
1286
|
+
* The caller (tune-out coordinator) owns the dump/notice flow; this is
|
|
1287
|
+
* only the durable state flip. No-op returning null if the channel is
|
|
1288
|
+
* not tuned out.
|
|
1289
|
+
*/
|
|
1290
|
+
cancelTuneOut(
|
|
1291
|
+
serverId: string,
|
|
1292
|
+
channelId: string,
|
|
1293
|
+
nextState: 'open' | 'closed',
|
|
1294
|
+
source: string,
|
|
1295
|
+
): { params: TuneOutParams; wakeCount: number } | null {
|
|
1296
|
+
const key = this.lifecycleKey(serverId, channelId);
|
|
1297
|
+
const current = this.desiredStates.get(key);
|
|
1298
|
+
if (current?.state !== 'tuned-out' || !current.tuneOut) return null;
|
|
1299
|
+
const ended = { params: current.tuneOut, wakeCount: current.wakeCount ?? 0 };
|
|
1300
|
+
this.desiredStates.set(key, { state: nextState, source });
|
|
1301
|
+
this.appendLifecycleEvent({
|
|
1302
|
+
kind: 'desired-state',
|
|
1303
|
+
serverId,
|
|
1304
|
+
channelId,
|
|
1305
|
+
desired: nextState,
|
|
1306
|
+
source,
|
|
1307
|
+
timestamp: new Date().toISOString(),
|
|
1308
|
+
});
|
|
1309
|
+
return ended;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* Durably record one wake invocation against the active epoch and return
|
|
1314
|
+
* the updated count with the params (the coordinator compares against
|
|
1315
|
+
* maxWakes and decides auto-cancel). Durable here, not in gate stats:
|
|
1316
|
+
* gate runtime state dies with the process, and a restart must not grant
|
|
1317
|
+
* a hammered channel a fresh wake budget.
|
|
1318
|
+
*/
|
|
1319
|
+
recordTuneOutWake(
|
|
1320
|
+
serverId: string,
|
|
1321
|
+
channelId: string,
|
|
1322
|
+
): { params: TuneOutParams; wakeCount: number } | null {
|
|
1323
|
+
const key = this.lifecycleKey(serverId, channelId);
|
|
1324
|
+
const current = this.desiredStates.get(key);
|
|
1325
|
+
if (current?.state !== 'tuned-out' || !current.tuneOut) return null;
|
|
1326
|
+
current.wakeCount = (current.wakeCount ?? 0) + 1;
|
|
1327
|
+
this.appendLifecycleEvent({
|
|
1328
|
+
kind: 'tune-out-wake',
|
|
1329
|
+
serverId,
|
|
1330
|
+
channelId,
|
|
1331
|
+
epochId: current.tuneOut.epochId,
|
|
1332
|
+
wakeCount: current.wakeCount,
|
|
1333
|
+
timestamp: new Date().toISOString(),
|
|
1334
|
+
});
|
|
1335
|
+
return { params: current.tuneOut, wakeCount: current.wakeCount };
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/** Active tune-out params + wake count for a channel, or null. */
|
|
1339
|
+
getTuneOutState(
|
|
1340
|
+
serverId: string,
|
|
1341
|
+
channelId: string,
|
|
1342
|
+
): { params: TuneOutParams; wakeCount: number } | null {
|
|
1343
|
+
const current = this.desiredStates.get(this.lifecycleKey(serverId, channelId));
|
|
1344
|
+
if (current?.state !== 'tuned-out' || !current.tuneOut) return null;
|
|
1345
|
+
return { params: current.tuneOut, wakeCount: current.wakeCount ?? 0 };
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/** Registered channel entries (read-only iteration for the coordinator). */
|
|
1349
|
+
listChannelsRaw(): Array<{ serverId: string; descriptor: ChannelDescriptor }> {
|
|
1350
|
+
return [...this.channels.values()].map((e) => ({
|
|
1351
|
+
serverId: e.serverId,
|
|
1352
|
+
descriptor: e.descriptor,
|
|
1353
|
+
}));
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* Small chronicle snapshot-state helpers for the tune-out coordinator
|
|
1358
|
+
* (dispositions slot). Registration is idempotent; absent store = null/no-op
|
|
1359
|
+
* (mirrors the lifecycle log's optional-store posture).
|
|
1360
|
+
*/
|
|
1361
|
+
readCoordinatorState(stateId: string): unknown {
|
|
1362
|
+
if (!this.store) return null;
|
|
1363
|
+
try {
|
|
1364
|
+
this.store.registerState({ id: stateId, strategy: 'snapshot' });
|
|
1365
|
+
} catch { /* already registered */ }
|
|
1366
|
+
return this.store.getStateJson(stateId);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
writeCoordinatorState(stateId: string, value: unknown): void {
|
|
1370
|
+
if (!this.store) return;
|
|
1371
|
+
try {
|
|
1372
|
+
this.store.registerState({ id: stateId, strategy: 'snapshot' });
|
|
1373
|
+
} catch { /* already registered */ }
|
|
1374
|
+
this.store.setStateJson(stateId, value);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* Publish into a channel on behalf of a named non-resident agent (the
|
|
1379
|
+
* subconscious's speak_in_channel). Same delivery path as the
|
|
1380
|
+
* channel_publish tool; the agent name rides the speech-routed trace.
|
|
1381
|
+
*/
|
|
1382
|
+
async publishForAgent(
|
|
1383
|
+
channelId: string,
|
|
1384
|
+
text: string,
|
|
1385
|
+
agentName: string,
|
|
1386
|
+
): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> {
|
|
1387
|
+
this.emitTraceFn({ type: 'mcpl:speech-routed', conversationId: agentName, channelId, text });
|
|
1388
|
+
return this.handleToolPublish({ channelId, content: text });
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1156
1391
|
/**
|
|
1157
1392
|
* Consume the old recipe policy exactly once. It seeds Chronicle for
|
|
1158
1393
|
* existing deployments, but is not an ongoing admission policy: channels
|
|
@@ -1269,7 +1504,10 @@ export class ChannelRegistry {
|
|
|
1269
1504
|
const key = `${serverId}:${channel.id}`;
|
|
1270
1505
|
const desired = this.getDesiredState(serverId, channel.id);
|
|
1271
1506
|
const entry = this.channels.get(key);
|
|
1272
|
-
|
|
1507
|
+
// Tuned-out sits on the OPEN side of reconcile: traffic must keep
|
|
1508
|
+
// arriving (the subconscious reads it); only main's wake/visibility
|
|
1509
|
+
// is diverted, downstream at ingestion.
|
|
1510
|
+
if (desired !== 'open' && desired !== 'tuned-out') {
|
|
1273
1511
|
try {
|
|
1274
1512
|
await server.sendChannelsClose({ channelId: channel.id });
|
|
1275
1513
|
if (entry) entry.open = false;
|
|
@@ -1682,6 +1920,22 @@ export class ChannelRegistry {
|
|
|
1682
1920
|
};
|
|
1683
1921
|
}
|
|
1684
1922
|
|
|
1923
|
+
// A tuned-out channel is transport-open but attention-diverted; a plain
|
|
1924
|
+
// open would silently discard the epoch (and its pending backlog dump).
|
|
1925
|
+
// Cancelling is the tune-out coordinator's flow, reached via its own
|
|
1926
|
+
// tool — refuse with the pointer rather than eat the state.
|
|
1927
|
+
if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
|
|
1928
|
+
return {
|
|
1929
|
+
success: false,
|
|
1930
|
+
isError: false,
|
|
1931
|
+
error:
|
|
1932
|
+
`Channel ${input.channelId} is tuned out. Cancel the tune-out ` +
|
|
1933
|
+
`(tune_out with mode: 'cancel') to resume normal attention — ` +
|
|
1934
|
+
`cancelling delivers the diverted backlog.`,
|
|
1935
|
+
data: { refusal: 'tuned-out', channelId: input.channelId },
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1685
1939
|
const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open';
|
|
1686
1940
|
if (entry.open && alreadyDesiredOpen && !input.backscroll) {
|
|
1687
1941
|
return {
|
|
@@ -1780,7 +2034,9 @@ export class ChannelRegistry {
|
|
|
1780
2034
|
// than retry — unless it certifies an explicit idle lease.
|
|
1781
2035
|
if (isModuleOrigin && closeSource !== 'agent-tool' && !input.overrideExplicitOpen) {
|
|
1782
2036
|
const current = this.desiredStates.get(this.lifecycleKey(entry.serverId, input.channelId));
|
|
1783
|
-
|
|
2037
|
+
// Tuned-out is stated intent too: a GC/housekeeping close would
|
|
2038
|
+
// silently end the epoch and orphan its backlog.
|
|
2039
|
+
if ((current?.state === 'open' || current?.state === 'tuned-out') && current.source === 'agent-tool') {
|
|
1784
2040
|
return {
|
|
1785
2041
|
success: false,
|
|
1786
2042
|
isError: false,
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { ContentBlock } from '@animalabs/membrane';
|
|
17
|
+
import { INLINE_WITHHELD_TEXT, isInlineContradiction, referenceStubOrNull } from './references.js';
|
|
17
18
|
import type { ContextInjection } from '@animalabs/context-manager';
|
|
18
19
|
|
|
19
20
|
import type {
|
|
@@ -65,6 +66,16 @@ function convertBlock(block: McplContentBlock): ContentBlock {
|
|
|
65
66
|
return { type: 'text', text: block.text };
|
|
66
67
|
|
|
67
68
|
case 'image':
|
|
69
|
+
if (isInlineContradiction(block)) {
|
|
70
|
+
// RFC-005 vector 2: inline data claiming bulk disposition — fail
|
|
71
|
+
// closed, withhold the data (checked BEFORE the data branch).
|
|
72
|
+
return { type: 'text', text: INLINE_WITHHELD_TEXT };
|
|
73
|
+
}
|
|
74
|
+
if (block.uri && block.disposition) {
|
|
75
|
+
// RFC-005 uri-form media with a disposition claim: stub, do not
|
|
76
|
+
// hand the URI to the provider or inline it.
|
|
77
|
+
return { type: 'text', text: referenceStubOrNull(block, 'attachment on context injection') ?? '[reference]' };
|
|
78
|
+
}
|
|
68
79
|
if (block.data && block.mimeType) {
|
|
69
80
|
return {
|
|
70
81
|
type: 'image',
|
|
@@ -81,6 +92,16 @@ function convertBlock(block: McplContentBlock): ContentBlock {
|
|
|
81
92
|
return { type: 'text', text: '[Image: missing data]' };
|
|
82
93
|
|
|
83
94
|
case 'audio':
|
|
95
|
+
if (isInlineContradiction(block)) {
|
|
96
|
+
// RFC-005 vector 2: inline data claiming bulk disposition — fail
|
|
97
|
+
// closed, withhold the data (checked BEFORE the data branch).
|
|
98
|
+
return { type: 'text', text: INLINE_WITHHELD_TEXT };
|
|
99
|
+
}
|
|
100
|
+
if (block.uri && block.disposition) {
|
|
101
|
+
// RFC-005 uri-form media with a disposition claim: stub, do not
|
|
102
|
+
// hand the URI to the provider or inline it.
|
|
103
|
+
return { type: 'text', text: referenceStubOrNull(block, 'attachment on context injection') ?? '[reference]' };
|
|
104
|
+
}
|
|
84
105
|
if (block.data && block.mimeType) {
|
|
85
106
|
return {
|
|
86
107
|
type: 'audio',
|
|
@@ -91,8 +112,14 @@ function convertBlock(block: McplContentBlock): ContentBlock {
|
|
|
91
112
|
return { type: 'text', text: `[Audio: ${block.uri ?? 'missing data'}]` };
|
|
92
113
|
|
|
93
114
|
case 'resource':
|
|
94
|
-
//
|
|
95
|
-
|
|
115
|
+
// RFC-005: reference blocks become bounded stubs — never raw URIs
|
|
116
|
+
// (a signed URL is a bearer credential that looks like a location).
|
|
117
|
+
return { type: 'text', text: referenceStubOrNull(block, 'attachment on context injection') ?? '[reference]' };
|
|
118
|
+
|
|
119
|
+
default:
|
|
120
|
+
// Unknown wire block types previously fell off the exhaustive switch
|
|
121
|
+
// and propagated `undefined` into ContentBlock[]. Fail visibly.
|
|
122
|
+
return { type: 'text', text: `[unrecognized content block: ${(block as { type?: string }).type ?? 'untyped'}]` };
|
|
96
123
|
}
|
|
97
124
|
}
|
|
98
125
|
|
package/src/mcpl/push-handler.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ContentBlock } from '@animalabs/membrane';
|
|
11
|
+
import { INLINE_WITHHELD_TEXT, isInlineContradiction, referenceStubOrNull } from './references.js';
|
|
11
12
|
|
|
12
13
|
import type {
|
|
13
14
|
McplContentBlock,
|
|
@@ -51,12 +52,22 @@ export interface McplPushEvent {
|
|
|
51
52
|
* Convert a single MCPL wire-format content block to a membrane ContentBlock.
|
|
52
53
|
* Same logic as hook-orchestrator.ts.
|
|
53
54
|
*/
|
|
54
|
-
function convertBlock(block: McplContentBlock): ContentBlock {
|
|
55
|
+
export function convertBlock(block: McplContentBlock): ContentBlock {
|
|
55
56
|
switch (block.type) {
|
|
56
57
|
case 'text':
|
|
57
58
|
return { type: 'text', text: block.text };
|
|
58
59
|
|
|
59
60
|
case 'image':
|
|
61
|
+
if (isInlineContradiction(block)) {
|
|
62
|
+
// RFC-005 vector 2: inline data claiming bulk disposition — fail
|
|
63
|
+
// closed, withhold the data (checked BEFORE the data branch).
|
|
64
|
+
return { type: 'text', text: INLINE_WITHHELD_TEXT };
|
|
65
|
+
}
|
|
66
|
+
if (block.uri && block.disposition) {
|
|
67
|
+
// RFC-005 uri-form media with a disposition claim: stub, do not
|
|
68
|
+
// hand the URI to the provider or inline it.
|
|
69
|
+
return { type: 'text', text: referenceStubOrNull(block, 'attachment on push event') ?? '[reference]' };
|
|
70
|
+
}
|
|
60
71
|
if (block.data && block.mimeType) {
|
|
61
72
|
return {
|
|
62
73
|
type: 'image',
|
|
@@ -72,6 +83,16 @@ function convertBlock(block: McplContentBlock): ContentBlock {
|
|
|
72
83
|
return { type: 'text', text: '[Image: no data]' };
|
|
73
84
|
|
|
74
85
|
case 'audio':
|
|
86
|
+
if (isInlineContradiction(block)) {
|
|
87
|
+
// RFC-005 vector 2: inline data claiming bulk disposition — fail
|
|
88
|
+
// closed, withhold the data (checked BEFORE the data branch).
|
|
89
|
+
return { type: 'text', text: INLINE_WITHHELD_TEXT };
|
|
90
|
+
}
|
|
91
|
+
if (block.uri && block.disposition) {
|
|
92
|
+
// RFC-005 uri-form media with a disposition claim: stub, do not
|
|
93
|
+
// hand the URI to the provider or inline it.
|
|
94
|
+
return { type: 'text', text: referenceStubOrNull(block, 'attachment on push event') ?? '[reference]' };
|
|
95
|
+
}
|
|
75
96
|
if (block.data && block.mimeType) {
|
|
76
97
|
return {
|
|
77
98
|
type: 'audio',
|
|
@@ -81,7 +102,14 @@ function convertBlock(block: McplContentBlock): ContentBlock {
|
|
|
81
102
|
return { type: 'text', text: '[Audio: no data]' };
|
|
82
103
|
|
|
83
104
|
case 'resource':
|
|
84
|
-
|
|
105
|
+
// RFC-005: reference blocks become bounded stubs — never raw URIs
|
|
106
|
+
// (a signed URL is a bearer credential that looks like a location).
|
|
107
|
+
return { type: 'text', text: referenceStubOrNull(block, 'attachment on push event') ?? '[reference]' };
|
|
108
|
+
|
|
109
|
+
default:
|
|
110
|
+
// Unknown wire block types previously fell off the exhaustive switch
|
|
111
|
+
// and propagated `undefined` into ContentBlock[]. Fail visibly.
|
|
112
|
+
return { type: 'text', text: `[unrecognized content block: ${(block as { type?: string }).type ?? 'untyped'}]` };
|
|
85
113
|
}
|
|
86
114
|
}
|
|
87
115
|
|