@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.
Files changed (160) hide show
  1. package/dist/src/agent.d.ts +11 -1
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +60 -4
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +72 -1
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +873 -123
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +7 -1
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +15 -1
  12. package/dist/src/gate/event-gate.js.map +1 -1
  13. package/dist/src/kv-unified-wire.d.ts +16 -0
  14. package/dist/src/kv-unified-wire.d.ts.map +1 -0
  15. package/dist/src/kv-unified-wire.js +2 -0
  16. package/dist/src/kv-unified-wire.js.map +1 -0
  17. package/dist/src/mcpl/channel-registry.d.ts +84 -1
  18. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  19. package/dist/src/mcpl/channel-registry.js +188 -5
  20. package/dist/src/mcpl/channel-registry.js.map +1 -1
  21. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  22. package/dist/src/mcpl/hook-orchestrator.js +28 -2
  23. package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
  24. package/dist/src/mcpl/push-handler.d.ts +6 -1
  25. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  26. package/dist/src/mcpl/push-handler.js +29 -2
  27. package/dist/src/mcpl/push-handler.js.map +1 -1
  28. package/dist/src/mcpl/reference-fetcher.d.ts +59 -0
  29. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -0
  30. package/dist/src/mcpl/reference-fetcher.js +240 -0
  31. package/dist/src/mcpl/reference-fetcher.js.map +1 -0
  32. package/dist/src/mcpl/references.d.ts +125 -0
  33. package/dist/src/mcpl/references.d.ts.map +1 -0
  34. package/dist/src/mcpl/references.js +262 -0
  35. package/dist/src/mcpl/references.js.map +1 -0
  36. package/dist/src/mcpl/types.d.ts +32 -0
  37. package/dist/src/mcpl/types.d.ts.map +1 -1
  38. package/dist/src/mcpl/types.js.map +1 -1
  39. package/dist/src/modules/index.d.ts +2 -1
  40. package/dist/src/modules/index.d.ts.map +1 -1
  41. package/dist/src/modules/index.js +1 -1
  42. package/dist/src/modules/index.js.map +1 -1
  43. package/dist/src/modules/workspace/index.d.ts +125 -2
  44. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  45. package/dist/src/modules/workspace/index.js +354 -58
  46. package/dist/src/modules/workspace/index.js.map +1 -1
  47. package/dist/src/modules/workspace/types.d.ts +7 -0
  48. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  49. package/dist/src/modules/workspace/types.js.map +1 -1
  50. package/dist/src/tool-result-history.d.ts.map +1 -1
  51. package/dist/src/tool-result-history.js +14 -0
  52. package/dist/src/tool-result-history.js.map +1 -1
  53. package/dist/src/tool-wrapper-prose-guard.d.ts +8 -0
  54. package/dist/src/tool-wrapper-prose-guard.d.ts.map +1 -0
  55. package/dist/src/tool-wrapper-prose-guard.js +37 -0
  56. package/dist/src/tool-wrapper-prose-guard.js.map +1 -0
  57. package/dist/src/tune-out/coordinator.d.ts +144 -0
  58. package/dist/src/tune-out/coordinator.d.ts.map +1 -0
  59. package/dist/src/tune-out/coordinator.js +444 -0
  60. package/dist/src/tune-out/coordinator.js.map +1 -0
  61. package/dist/src/tune-out/tools.d.ts +43 -0
  62. package/dist/src/tune-out/tools.d.ts.map +1 -0
  63. package/dist/src/tune-out/tools.js +93 -0
  64. package/dist/src/tune-out/tools.js.map +1 -0
  65. package/dist/src/types/agent.d.ts +15 -0
  66. package/dist/src/types/agent.d.ts.map +1 -1
  67. package/dist/src/types/framework.d.ts +6 -0
  68. package/dist/src/types/framework.d.ts.map +1 -1
  69. package/dist/src/usage-accounting.d.ts +5 -0
  70. package/dist/src/usage-accounting.d.ts.map +1 -0
  71. package/dist/src/usage-accounting.js +11 -0
  72. package/dist/src/usage-accounting.js.map +1 -0
  73. package/dist/test/channel-incoming-targeting.test.d.ts +2 -0
  74. package/dist/test/channel-incoming-targeting.test.d.ts.map +1 -0
  75. package/dist/test/channel-incoming-targeting.test.js +81 -0
  76. package/dist/test/channel-incoming-targeting.test.js.map +1 -0
  77. package/dist/test/conversation-routing.test.js +39 -0
  78. package/dist/test/conversation-routing.test.js.map +1 -1
  79. package/dist/test/kv-unified-wiring.test.d.ts +2 -0
  80. package/dist/test/kv-unified-wiring.test.d.ts.map +1 -0
  81. package/dist/test/kv-unified-wiring.test.js +56 -0
  82. package/dist/test/kv-unified-wiring.test.js.map +1 -0
  83. package/dist/test/mcpl-push-convert.test.d.ts +2 -0
  84. package/dist/test/mcpl-push-convert.test.d.ts.map +1 -0
  85. package/dist/test/mcpl-push-convert.test.js +42 -0
  86. package/dist/test/mcpl-push-convert.test.js.map +1 -0
  87. package/dist/test/mcpl-reference-fetcher.test.d.ts +2 -0
  88. package/dist/test/mcpl-reference-fetcher.test.d.ts.map +1 -0
  89. package/dist/test/mcpl-reference-fetcher.test.js +199 -0
  90. package/dist/test/mcpl-reference-fetcher.test.js.map +1 -0
  91. package/dist/test/per-agent-delivery.test.d.ts +2 -0
  92. package/dist/test/per-agent-delivery.test.d.ts.map +1 -0
  93. package/dist/test/per-agent-delivery.test.js +108 -0
  94. package/dist/test/per-agent-delivery.test.js.map +1 -0
  95. package/dist/test/present-while-acting.test.js +64 -0
  96. package/dist/test/present-while-acting.test.js.map +1 -1
  97. package/dist/test/tool-result-history.test.js +78 -0
  98. package/dist/test/tool-result-history.test.js.map +1 -1
  99. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts +2 -0
  100. package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts.map +1 -0
  101. package/dist/test/tool-wrapper-prose-guard-integration.test.js +485 -0
  102. package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -0
  103. package/dist/test/tool-wrapper-prose-guard.test.d.ts +2 -0
  104. package/dist/test/tool-wrapper-prose-guard.test.d.ts.map +1 -0
  105. package/dist/test/tool-wrapper-prose-guard.test.js +34 -0
  106. package/dist/test/tool-wrapper-prose-guard.test.js.map +1 -0
  107. package/dist/test/tune-out-e2e.test.d.ts +2 -0
  108. package/dist/test/tune-out-e2e.test.d.ts.map +1 -0
  109. package/dist/test/tune-out-e2e.test.js +142 -0
  110. package/dist/test/tune-out-e2e.test.js.map +1 -0
  111. package/dist/test/tune-out-gate-composition.test.d.ts +2 -0
  112. package/dist/test/tune-out-gate-composition.test.d.ts.map +1 -0
  113. package/dist/test/tune-out-gate-composition.test.js +221 -0
  114. package/dist/test/tune-out-gate-composition.test.js.map +1 -0
  115. package/dist/test/tune-out-lifecycle.test.d.ts +2 -0
  116. package/dist/test/tune-out-lifecycle.test.d.ts.map +1 -0
  117. package/dist/test/tune-out-lifecycle.test.js +121 -0
  118. package/dist/test/tune-out-lifecycle.test.js.map +1 -0
  119. package/dist/test/turn-trigger-provenance.test.d.ts +2 -0
  120. package/dist/test/turn-trigger-provenance.test.d.ts.map +1 -0
  121. package/dist/test/turn-trigger-provenance.test.js +85 -0
  122. package/dist/test/turn-trigger-provenance.test.js.map +1 -0
  123. package/dist/test/usage-accounting.test.d.ts +2 -0
  124. package/dist/test/usage-accounting.test.d.ts.map +1 -0
  125. package/dist/test/usage-accounting.test.js +13 -0
  126. package/dist/test/usage-accounting.test.js.map +1 -0
  127. package/dist/test/workspace-branch-continuation.test.d.ts +15 -0
  128. package/dist/test/workspace-branch-continuation.test.d.ts.map +1 -0
  129. package/dist/test/workspace-branch-continuation.test.js +183 -0
  130. package/dist/test/workspace-branch-continuation.test.js.map +1 -0
  131. package/dist/test/workspace-read-file-from-disk.test.d.ts +17 -0
  132. package/dist/test/workspace-read-file-from-disk.test.d.ts.map +1 -0
  133. package/dist/test/workspace-read-file-from-disk.test.js +174 -0
  134. package/dist/test/workspace-read-file-from-disk.test.js.map +1 -0
  135. package/dist/test/workspace-read-image.test.js +8 -0
  136. package/dist/test/workspace-read-image.test.js.map +1 -1
  137. package/dist/test/workspace-restore-order.test.js +1 -1
  138. package/dist/test/workspace-restore-order.test.js.map +1 -1
  139. package/dist/tsconfig.tsbuildinfo +1 -1
  140. package/package.json +2 -2
  141. package/src/agent.ts +77 -4
  142. package/src/framework.ts +953 -128
  143. package/src/gate/event-gate.ts +28 -3
  144. package/src/kv-unified-wire.ts +12 -0
  145. package/src/mcpl/channel-registry.ts +264 -8
  146. package/src/mcpl/hook-orchestrator.ts +29 -2
  147. package/src/mcpl/push-handler.ts +30 -2
  148. package/src/mcpl/reference-fetcher.ts +240 -0
  149. package/src/mcpl/references.ts +292 -0
  150. package/src/mcpl/types.ts +29 -0
  151. package/src/modules/index.ts +2 -1
  152. package/src/modules/workspace/index.ts +425 -66
  153. package/src/modules/workspace/types.ts +7 -0
  154. package/src/tool-result-history.ts +11 -0
  155. package/src/tool-wrapper-prose-guard.ts +38 -0
  156. package/src/tune-out/coordinator.ts +548 -0
  157. package/src/tune-out/tools.ts +129 -0
  158. package/src/types/agent.ts +16 -0
  159. package/src/types/framework.ts +7 -0
  160. package/src/usage-accounting.ts +8 -0
@@ -0,0 +1,240 @@
1
+ /**
2
+ * RFC-005 host-mediated reference fetcher (§6, §7) — the only code that ever
3
+ * dereferences a reference, and it fails closed on every axis the RFC names:
4
+ *
5
+ * - **origin binding (§6.1, revision 3):** authenticated fetches go only to
6
+ * the origin the reference's server connection was dialed to. This v1 does
7
+ * not fetch third-party origins at all (deferred with declared reference
8
+ * origins); the connection credential is applied as an Authorization
9
+ * header by this code alone and never surfaces to model, script, or URI.
10
+ * - **scheme allowlist (§7.1, vector 11):** https; http only when the dialed
11
+ * origin itself is plaintext (a ws:// server references its own origin).
12
+ * - **no redirect traversal (§7.2, vector 12):** any 3xx refuses.
13
+ * - **actual-byte ceiling (§7.3 list, vector 10):** streamed with abort the
14
+ * moment real octets exceed the ceiling, regardless of claimed sizeBytes.
15
+ * A claim already over the ceiling refuses without dialing (vector 4).
16
+ * - **digest verification (§7.4 list, vector 5):** sha256 computed while
17
+ * streaming; a mismatch is discarded, never presented.
18
+ * - **expiry (§7.4, vector 16):** expired testimony refuses locally.
19
+ * - **storage naming (§7.3):** the file name is host-generated from the
20
+ * reference id + verified type; the server's `name` is display-only.
21
+ *
22
+ * Autofetch policy (host default, not protocol): eager only below
23
+ * DEFAULT_EAGER_FETCH_MAX_BYTES from the connection origin — anything under
24
+ * that threshold the server could legitimately have inlined, so eager
25
+ * materialization is never worse than the status quo. Everything larger is
26
+ * fetched on demand (the fetch_reference tool, or the code-execution result
27
+ * hook, where a script receiving a result is the strongest available signal
28
+ * that the bytes are wanted). Per-server budgets cap cumulative appetite.
29
+ */
30
+ import { createHash } from 'node:crypto';
31
+ import type { ReferenceRecord } from './references.js';
32
+ import { isReferenceExpired } from './references.js';
33
+
34
+ /** Per-fetch hard ceiling on actual bytes (overridable per server / call). */
35
+ export const DEFAULT_FETCH_MAX_BYTES = 256 * 1024 * 1024;
36
+ /** Eager (autofetch) threshold: at most what could have shipped inline. */
37
+ export const DEFAULT_EAGER_FETCH_MAX_BYTES = 256 * 1024;
38
+ /** Cumulative per-server budget for this process. */
39
+ export const DEFAULT_MAX_TOTAL_BYTES = 1024 * 1024 * 1024;
40
+ const FETCH_TIMEOUT_MS = 60_000;
41
+ const EAGER_FETCH_TIMEOUT_MS = 10_000;
42
+
43
+ export interface ResolvedServerBinding {
44
+ /** The url the connection was dialed to (ws/wss/http/https). */
45
+ url: string;
46
+ /** Connection credential, applied host-side as Authorization: Bearer. */
47
+ token?: string;
48
+ autofetch?: { maxBytes?: number; maxTotalBytes?: number };
49
+ }
50
+
51
+ export interface FetchOutcome {
52
+ ok: boolean;
53
+ /** Mount-prefixed workspace path of the saved payload (on success). */
54
+ path?: string;
55
+ bytes?: number;
56
+ mimeType?: string;
57
+ digestVerified?: boolean;
58
+ error?: string;
59
+ }
60
+
61
+ /** Magic-byte sniff for the formats this pipeline actually moves. The
62
+ * header is testimony too (vector 13): sniffed type wins for the storage
63
+ * extension and the record's verified type; header, then testimony, are
64
+ * fallbacks for containers the sniffer doesn't know. */
65
+ export function sniffMime(data: Buffer): string | null {
66
+ if (data.length < 12) return null;
67
+ if (data.subarray(0, 4).toString('latin1') === 'RIFF' && data.subarray(8, 12).toString('latin1') === 'WAVE') return 'audio/wav';
68
+ if (data.subarray(0, 4).toString('latin1') === 'RIFF') return 'audio/wav';
69
+ if (data.subarray(0, 4).toString('latin1') === 'fLaC') return 'audio/flac';
70
+ if (data.subarray(0, 4).toString('latin1') === 'OggS') return 'audio/ogg';
71
+ if (data.subarray(0, 4).toString('latin1') === 'MThd') return 'audio/midi';
72
+ if (data.subarray(0, 3).toString('latin1') === 'ID3' || (data[0] === 0xff && (data[1] & 0xe0) === 0xe0)) return 'audio/mpeg';
73
+ if (data[0] === 0x89 && data.subarray(1, 4).toString('latin1') === 'PNG') return 'image/png';
74
+ if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return 'image/jpeg';
75
+ if (data.subarray(0, 6).toString('latin1') === 'GIF87a' || data.subarray(0, 6).toString('latin1') === 'GIF89a') return 'image/gif';
76
+ if (data.subarray(4, 8).toString('latin1') === 'ftyp') return 'video/mp4';
77
+ if (data.subarray(0, 5).toString('latin1') === '%PDF-') return 'application/pdf';
78
+ if (data[0] === 0x50 && data[1] === 0x4b) return 'application/zip';
79
+ return null;
80
+ }
81
+
82
+ const EXT_BY_MIME: Record<string, string> = {
83
+ 'audio/wav': 'wav', 'audio/x-wav': 'wav', 'audio/mpeg': 'mp3', 'audio/mp4': 'm4a',
84
+ 'audio/flac': 'flac', 'audio/midi': 'mid', 'audio/ogg': 'ogg',
85
+ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp',
86
+ 'video/mp4': 'mp4', 'application/json': 'json', 'text/plain': 'txt',
87
+ 'application/pdf': 'pdf', 'application/zip': 'zip',
88
+ };
89
+
90
+ /** ws→http / wss→https with default ports elided, for origin comparison. */
91
+ export function normalizedOrigin(url: string): string | null {
92
+ try {
93
+ const u = new URL(url);
94
+ const scheme = u.protocol === 'ws:' ? 'http:' : u.protocol === 'wss:' ? 'https:' : u.protocol;
95
+ const defaultPort = scheme === 'https:' ? '443' : '80';
96
+ const port = u.port === '' || u.port === defaultPort ? '' : `:${u.port}`;
97
+ return `${scheme}//${u.hostname.toLowerCase()}${port}`;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ export class ReferenceFetcher {
104
+ private totals = new Map<string, number>();
105
+ private inFlight = new Map<string, Promise<FetchOutcome>>();
106
+
107
+ constructor(
108
+ private readonly resolveServer: (serverId: string) => ResolvedServerBinding | undefined,
109
+ /** Persist the payload; returns a mount-prefixed path or null when no
110
+ * writable workspace exists. File name is host-generated (§7.3). */
111
+ private readonly saveFile: (fileName: string, data: Buffer, mimeType: string) => Promise<string | null>,
112
+ ) {}
113
+
114
+ /** Would the eager (autofetch) default fetch this record? */
115
+ isEagerEligible(record: ReferenceRecord): boolean {
116
+ if (record.fetchedPath) return false;
117
+ if (record.serverId === undefined) return false;
118
+ const binding = this.resolveServer(record.serverId);
119
+ if (!binding) return false;
120
+ const cap = binding.autofetch?.maxBytes ?? DEFAULT_EAGER_FETCH_MAX_BYTES;
121
+ if (record.testimony.sizeBytes === undefined || record.testimony.sizeBytes > cap) return false;
122
+ return this.originCheck(record, binding) === null;
123
+ }
124
+
125
+ /** §6.1/§7.1 policy check; returns a refusal reason or null. */
126
+ private originCheck(record: ReferenceRecord, binding: ResolvedServerBinding): string | null {
127
+ const refOrigin = normalizedOrigin(record.testimony.uri);
128
+ const dialedOrigin = normalizedOrigin(binding.url);
129
+ if (!refOrigin) return 'reference uri does not parse';
130
+ if (!dialedOrigin) return 'server url does not parse';
131
+ const scheme = refOrigin.split('//')[0];
132
+ if (scheme !== 'https:' && scheme !== 'http:') return `scheme not allowed: ${scheme}`;
133
+ if (refOrigin !== dialedOrigin) {
134
+ return `third-party origin (${refOrigin} != dialed ${dialedOrigin}); not fetched by this host`;
135
+ }
136
+ if (scheme === 'http:' && dialedOrigin.startsWith('https:')) {
137
+ return 'plaintext reference from a secure origin';
138
+ }
139
+ return null;
140
+ }
141
+
142
+ /** Fetch a reference to workspace storage. Idempotent per record; concurrent
143
+ * callers share one in-flight fetch. */
144
+ fetch(record: ReferenceRecord, opts?: { maxBytes?: number; timeoutMs?: number }): Promise<FetchOutcome> {
145
+ if (record.fetchedPath) {
146
+ return Promise.resolve({ ok: true, path: record.fetchedPath, bytes: record.verifiedBytes,
147
+ mimeType: record.verifiedMimeType, digestVerified: record.digestVerified });
148
+ }
149
+ const existing = this.inFlight.get(record.refId);
150
+ if (existing) return existing;
151
+ const p = this.doFetch(record, opts).finally(() => this.inFlight.delete(record.refId));
152
+ this.inFlight.set(record.refId, p);
153
+ return p;
154
+ }
155
+
156
+ private async doFetch(record: ReferenceRecord, opts?: { maxBytes?: number; timeoutMs?: number }): Promise<FetchOutcome> {
157
+ const t = record.testimony;
158
+ if (record.serverId === undefined) return { ok: false, error: 'reference has no server binding' };
159
+ const binding = this.resolveServer(record.serverId);
160
+ if (!binding) return { ok: false, error: `unknown server: ${record.serverId}` };
161
+
162
+ const refusal = this.originCheck(record, binding);
163
+ if (refusal) return { ok: false, error: refusal };
164
+ if (isReferenceExpired(t)) return { ok: false, error: 'reference expired (or unparseable expiry — fail closed)' };
165
+
166
+ const ceiling = opts?.maxBytes ?? DEFAULT_FETCH_MAX_BYTES;
167
+ // Vector 4: a claim already over the ceiling refuses without dialing.
168
+ if (t.sizeBytes !== undefined && t.sizeBytes > ceiling) {
169
+ return { ok: false, error: `claimed ${t.sizeBytes} bytes exceeds fetch ceiling ${ceiling}` };
170
+ }
171
+ const budget = binding.autofetch?.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
172
+ const used = this.totals.get(record.serverId) ?? 0;
173
+ if (used >= budget) {
174
+ return { ok: false, error: `per-server fetch budget exhausted (${used}/${budget} bytes)` };
175
+ }
176
+
177
+ const controller = new AbortController();
178
+ const timer = setTimeout(() => controller.abort(), opts?.timeoutMs ?? FETCH_TIMEOUT_MS);
179
+ try {
180
+ const headers: Record<string, string> = {};
181
+ if (binding.token) headers.authorization = `Bearer ${binding.token}`;
182
+ const resp = await fetch(t.uri, { headers, redirect: 'manual', signal: controller.signal });
183
+ if (resp.status >= 300 && resp.status < 400) {
184
+ return { ok: false, error: `redirect refused (HTTP ${resp.status}) — no redirect traversal` };
185
+ }
186
+ if (!resp.ok) return { ok: false, error: `HTTP ${resp.status}` };
187
+ if (!resp.body) return { ok: false, error: 'no response body' };
188
+
189
+ const hash = createHash('sha256');
190
+ const chunks: Buffer[] = [];
191
+ let received = 0;
192
+ const reader = resp.body.getReader();
193
+ for (;;) {
194
+ const { done, value } = await reader.read();
195
+ if (done) break;
196
+ received += value.byteLength;
197
+ if (received > ceiling || received > budget - used) {
198
+ controller.abort();
199
+ const which = received > ceiling ? 'ceiling' : 'per-server budget';
200
+ return { ok: false, error: `aborted: actual bytes exceeded ${which} at ${received}` };
201
+ }
202
+ const buf = Buffer.from(value);
203
+ hash.update(buf);
204
+ chunks.push(buf);
205
+ }
206
+ const data = Buffer.concat(chunks);
207
+
208
+ let digestVerified: boolean | undefined;
209
+ if (t.digest) {
210
+ const got = 'sha256:' + hash.digest('base64url');
211
+ if (got !== t.digest) {
212
+ // Vector 5: never presented as the described content.
213
+ return { ok: false, error: `digest mismatch (claimed ${t.digest.slice(0, 20)}…, got ${got.slice(0, 20)}…)` };
214
+ }
215
+ digestVerified = true;
216
+ }
217
+
218
+ const headerMime = resp.headers.get('content-type')?.split(';')[0].trim() || null;
219
+ const observedMime = sniffMime(data) ?? headerMime ?? t.mimeType ?? 'application/octet-stream';
220
+ const ext = EXT_BY_MIME[observedMime] ?? EXT_BY_MIME[headerMime ?? ''] ?? EXT_BY_MIME[t.mimeType ?? ''] ?? 'bin';
221
+ // §7.3: host-generated storage name; the server's `name` is display-only.
222
+ const path = await this.saveFile(`${record.refId}.${ext}`, data, observedMime);
223
+ if (path === null) return { ok: false, error: 'no writable workspace mount to store the payload' };
224
+
225
+ this.totals.set(record.serverId, used + data.length);
226
+ record.fetchedPath = path;
227
+ record.verifiedBytes = data.length;
228
+ record.verifiedMimeType = observedMime;
229
+ record.digestVerified = digestVerified;
230
+ return { ok: true, path, bytes: data.length, mimeType: observedMime, digestVerified };
231
+ } catch (e) {
232
+ const msg = (e as Error).name === 'AbortError' ? 'fetch timed out or aborted' : (e as Error).message;
233
+ return { ok: false, error: msg };
234
+ } finally {
235
+ clearTimeout(timer);
236
+ }
237
+ }
238
+ }
239
+
240
+ export { EAGER_FETCH_TIMEOUT_MS };
@@ -0,0 +1,292 @@
1
+ /**
2
+ * RFC-005 bulk content references — host treatment (agent-framework side).
3
+ *
4
+ * Mirrors the pure module in mcpl-core-ts `src/references.ts` (this framework
5
+ * deliberately does not depend on that package; hand-maintained MCPL types
6
+ * are the house convention — see types.ts header). Semantics are the RFC's,
7
+ * revision 3:
8
+ *
9
+ * - every parsed field is server *testimony*, never a fact (§3);
10
+ * - the one invalid-field rule (§8): a bad optional field is dropped while
11
+ * the block and its subtractive `disposition` survive; a bad `uri`
12
+ * rejects the block whole;
13
+ * - `disposition:"never"` withholds payload AND uri from model context
14
+ * unconditionally — enforced in code here, not by caller discipline (§5);
15
+ * - stub size is independent of every server-supplied field length (§5);
16
+ * - unparseable `expiresAt` fails closed (§7.4);
17
+ * - unknown block types never propagate `undefined` (the pre-existing
18
+ * convertBlock switches had no default — a latent corruption this module
19
+ * retires).
20
+ *
21
+ * The ReferenceRegistry is the host-private reference record (§6.2): raw
22
+ * URIs and testimony live here and never in model-visible content. Fetching
23
+ * (§6/§7 origin binding, ceilings, redirects) is deliberately not here yet —
24
+ * the registry is what makes a future host-mediated fetcher and the
25
+ * code-execution materialization hook possible.
26
+ */
27
+
28
+ export const REFERENCE_LIMITS = { uri: 4096, mimeType: 255, name: 255, expiresAt: 64 } as const;
29
+ export const DIGEST_PATTERN = /^sha256:[A-Za-z0-9_-]{43}$/;
30
+ const MAX_SAFE = 9007199254740991; // 2^53 - 1
31
+ const STUB_FIELD_CHARS = 120;
32
+
33
+ export type ReferenceDisposition = 'never' | 'ref';
34
+
35
+ export interface ReferenceTestimony {
36
+ uri: string;
37
+ mimeType?: string;
38
+ sizeBytes?: number;
39
+ digest?: string;
40
+ expiresAt?: string;
41
+ name?: string;
42
+ disposition?: ReferenceDisposition;
43
+ rejectedFields: string[];
44
+ truncatedFields: string[];
45
+ }
46
+
47
+ export type BlockClassification =
48
+ | { kind: 'text' }
49
+ | { kind: 'inline'; contradiction: boolean }
50
+ | { kind: 'reference'; testimony: ReferenceTestimony }
51
+ | { kind: 'invalid'; reason: string };
52
+
53
+ function isRecord(x: unknown): x is Record<string, unknown> {
54
+ return typeof x === 'object' && x !== null && !Array.isArray(x);
55
+ }
56
+
57
+ function takeString(
58
+ raw: unknown, field: keyof typeof REFERENCE_LIMITS & string,
59
+ out: { rejected: string[]; truncated: string[] },
60
+ ): string | undefined {
61
+ if (raw === undefined) return undefined;
62
+ if (typeof raw !== 'string') { out.rejected.push(field); return undefined; }
63
+ const limit = REFERENCE_LIMITS[field];
64
+ if (raw.length > limit) { out.truncated.push(field); return raw.slice(0, limit); }
65
+ return raw;
66
+ }
67
+
68
+ export function classifyBlock(block: unknown): BlockClassification {
69
+ if (!isRecord(block) || typeof block.type !== 'string') {
70
+ return { kind: 'invalid', reason: 'not a content block' };
71
+ }
72
+ switch (block.type) {
73
+ case 'text':
74
+ return typeof block.text === 'string'
75
+ ? { kind: 'text' }
76
+ : { kind: 'invalid', reason: 'text block without string text' };
77
+ case 'image':
78
+ case 'audio': {
79
+ const hasData = typeof block.data === 'string';
80
+ const hasUri = typeof block.uri === 'string';
81
+ if (hasData) {
82
+ // Inline form claiming bulk disposition (or smuggling a uri beside
83
+ // the data) is the vector-2 contradiction: fail closed, withhold.
84
+ return { kind: 'inline', contradiction: block.disposition !== undefined || hasUri };
85
+ }
86
+ if (hasUri) return parseReferenceFields(block);
87
+ return { kind: 'invalid', reason: `${block.type} block with neither data nor uri` };
88
+ }
89
+ case 'resource':
90
+ return parseReferenceFields(block);
91
+ default:
92
+ return { kind: 'invalid', reason: `unknown block type: ${block.type}` };
93
+ }
94
+ }
95
+
96
+ function parseReferenceFields(block: Record<string, unknown>): BlockClassification {
97
+ if (typeof block.uri !== 'string' || block.uri.length === 0) {
98
+ return { kind: 'invalid', reason: 'reference without string uri' };
99
+ }
100
+ if (block.uri.length > REFERENCE_LIMITS.uri) {
101
+ return { kind: 'invalid', reason: 'uri exceeds schema maximum' };
102
+ }
103
+ const out = { rejected: [] as string[], truncated: [] as string[] };
104
+ const t: ReferenceTestimony = { uri: block.uri, rejectedFields: out.rejected, truncatedFields: out.truncated };
105
+ t.mimeType = takeString(block.mimeType, 'mimeType', out);
106
+ t.name = takeString(block.name, 'name', out);
107
+ t.expiresAt = takeString(block.expiresAt, 'expiresAt', out);
108
+ if (block.sizeBytes !== undefined) {
109
+ const n = block.sizeBytes;
110
+ if (typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= MAX_SAFE) t.sizeBytes = n;
111
+ else out.rejected.push('sizeBytes');
112
+ }
113
+ if (block.digest !== undefined) {
114
+ if (typeof block.digest === 'string' && DIGEST_PATTERN.test(block.digest)) t.digest = block.digest;
115
+ else out.rejected.push('digest');
116
+ }
117
+ if (block.disposition !== undefined) {
118
+ if (block.disposition === 'never' || block.disposition === 'ref') t.disposition = block.disposition;
119
+ else out.rejected.push('disposition');
120
+ }
121
+ return { kind: 'reference', testimony: t };
122
+ }
123
+
124
+ /** RFC-005 vector 2: an inline-data block claiming bulk disposition (or
125
+ * smuggling a uri beside its data) is an emitter contradiction — the host
126
+ * fails closed by withholding the inline data from context. Every lane that
127
+ * handles inline media MUST consult this before its data branch. */
128
+ export function isInlineContradiction(block: unknown): boolean {
129
+ const c = classifyBlock(block);
130
+ return c.kind === 'inline' && c.contradiction;
131
+ }
132
+
133
+ export const INLINE_WITHHELD_TEXT =
134
+ '[inline content withheld: nonconformant bulk disposition on inline data]';
135
+
136
+ export function isReferenceExpired(t: Pick<ReferenceTestimony, 'expiresAt'>, nowMs = Date.now()): boolean {
137
+ if (t.expiresAt === undefined) return false;
138
+ const exp = Date.parse(t.expiresAt);
139
+ return Number.isNaN(exp) ? true : exp <= nowMs;
140
+ }
141
+
142
+ /** Strip C0/C1 controls plus bidi marks/overrides/isolates; bound length.
143
+ * Labels are labels — storage naming is host-generated elsewhere (§7.3). */
144
+ export function sanitizeLabel(s: string, maxChars: number): string {
145
+ const cleaned = s.replace(/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, '');
146
+ if (cleaned.length <= maxChars) return cleaned;
147
+ let cut = cleaned.slice(0, Math.max(0, maxChars - 1));
148
+ // never split a surrogate pair at the truncation boundary
149
+ const last = cut.charCodeAt(cut.length - 1);
150
+ if (last >= 0xd800 && last <= 0xdbff) cut = cut.slice(0, -1);
151
+ return cut + '…';
152
+ }
153
+
154
+ export function formatSizeBytes(n: number): string {
155
+ if (n < 1024) return `${n}B`;
156
+ if (n < 1024 * 1024) return `~${Math.round(n / 1024)}KB`;
157
+ if (n < 1024 * 1024 * 1024) return `~${(n / (1024 * 1024)).toFixed(1)}MB`;
158
+ return `~${(n / (1024 * 1024 * 1024)).toFixed(1)}GB`;
159
+ }
160
+
161
+ // ============================================================================
162
+ // Host-private reference record (§6.2) + reference ids (§5)
163
+ // ============================================================================
164
+
165
+ export interface ReferenceRecord {
166
+ refId: string;
167
+ serverId?: string;
168
+ testimony: ReferenceTestimony;
169
+ receivedAt: number;
170
+ /** Set by the host-mediated fetcher after a successful, verified fetch. */
171
+ fetchedPath?: string;
172
+ verifiedBytes?: number;
173
+ verifiedMimeType?: string;
174
+ digestVerified?: boolean;
175
+ }
176
+
177
+ const MAX_RECORDS = 2000;
178
+
179
+ /**
180
+ * Host-private store of received references. Raw URIs live here and in stubs
181
+ * never (under `never`) or by policy only (`ref`/absent). Ids are stable and
182
+ * never reused within the process (vector 20); eviction is FIFO past
183
+ * MAX_RECORDS and a stale lookup returns undefined — a defined miss, never a
184
+ * different record.
185
+ *
186
+ * Process-wide singleton by design: ids are host-local *names*, not
187
+ * capabilities, and the record itself carries serverId for any future
188
+ * origin-bound fetcher.
189
+ */
190
+ export class ReferenceRegistry {
191
+ private records = new Map<string, ReferenceRecord>();
192
+ /** uri → refId. The uri ALONE is the identity key: stub sites (the two
193
+ * tool-result serializers and the three converters) are free functions
194
+ * with no server context, while dispatch-time pre-registration carries a
195
+ * serverId — keying on both split every reference into two records, the
196
+ * model-visible one unbound (fetch_reference failed on it) and the bound
197
+ * one invisible. One key, and a later registration that knows the server
198
+ * UPGRADES the record's binding instead of forking it. If two servers ever
199
+ * reference the same uri, the first binding wins (the record's serverId
200
+ * feeds the origin check, which fails closed on a mismatch). */
201
+ private byKey = new Map<string, string>();
202
+ private counter = 0;
203
+
204
+ register(testimony: ReferenceTestimony, serverId?: string): ReferenceRecord {
205
+ const existingId = this.byKey.get(testimony.uri);
206
+ if (existingId !== undefined) {
207
+ const existing = this.records.get(existingId);
208
+ if (existing) {
209
+ if (serverId !== undefined && existing.serverId === undefined) {
210
+ existing.serverId = serverId;
211
+ }
212
+ // Refresh testimony while unfetched: a re-issued reference with a new
213
+ // digest/expiry supersedes the stale claims. Once fetched, the record
214
+ // describes verified bytes on disk and keeps the testimony they were
215
+ // verified against.
216
+ if (!existing.fetchedPath) existing.testimony = testimony;
217
+ return existing;
218
+ }
219
+ }
220
+ const refId = `ref_${(++this.counter).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
221
+ const record: ReferenceRecord = { refId, serverId, testimony, receivedAt: Date.now() };
222
+ this.records.set(refId, record);
223
+ this.byKey.set(testimony.uri, refId);
224
+ if (this.records.size > MAX_RECORDS) {
225
+ const oldest = this.records.keys().next().value;
226
+ if (oldest !== undefined) {
227
+ const old = this.records.get(oldest);
228
+ this.records.delete(oldest);
229
+ if (old) this.byKey.delete(old.testimony.uri);
230
+ }
231
+ }
232
+ return record;
233
+ }
234
+
235
+ get(refId: string): ReferenceRecord | undefined {
236
+ return this.records.get(refId);
237
+ }
238
+
239
+ /** Find the record for a uri. */
240
+ findByUri(uri: string): ReferenceRecord | undefined {
241
+ const id = this.byKey.get(uri);
242
+ return id !== undefined ? this.records.get(id) : undefined;
243
+ }
244
+ }
245
+
246
+ export const referenceRegistry = new ReferenceRegistry();
247
+
248
+ // ============================================================================
249
+ // Stub building (§5)
250
+ // ============================================================================
251
+
252
+ /**
253
+ * Build the model-visible stub for a reference. Output length is bounded by
254
+ * host constants and independent of every server-supplied field length.
255
+ * Under `disposition:"never"` the uri is omitted unconditionally; under
256
+ * `ref`/absent it is omitted too — this host's default policy is the opaque
257
+ * id everywhere (the safe default of §5), with retrieval via the record.
258
+ */
259
+ export function buildReferenceStub(t: ReferenceTestimony, provenance?: string, serverId?: string): string {
260
+ const record = referenceRegistry.register(t, serverId);
261
+ const parts: string[] = [];
262
+ if (t.name) parts.push(sanitizeLabel(t.name, STUB_FIELD_CHARS));
263
+ const meta: string[] = [];
264
+ if (t.mimeType) meta.push(sanitizeLabel(t.mimeType, STUB_FIELD_CHARS));
265
+ if (t.sizeBytes !== undefined) meta.push(`${formatSizeBytes(t.sizeBytes)} claimed`);
266
+ if (meta.length) parts.push(meta.join(', '));
267
+ if (provenance) parts.push(sanitizeLabel(provenance, STUB_FIELD_CHARS));
268
+ if (record.fetchedPath) {
269
+ // Already materialized by the host-mediated fetcher: hand the model the
270
+ // workspace path (host-generated, §7.3) so existing tools just work.
271
+ parts.push(`saved: ${sanitizeLabel(record.fetchedPath, 256)}`);
272
+ } else {
273
+ parts.push('fetch with fetch_reference');
274
+ }
275
+ return `[${record.refId}] ${parts.join(' — ') || 'referenced content'}`;
276
+ }
277
+
278
+ /**
279
+ * RFC-005 treatment for one MCPL wire block, for the model-visible text
280
+ * lanes. Returns a replacement string, or null when the block should keep
281
+ * its existing (inline/text) handling.
282
+ */
283
+ export function referenceStubOrNull(block: unknown, provenance?: string, serverId?: string): string | null {
284
+ const c = classifyBlock(block);
285
+ switch (c.kind) {
286
+ case 'text': return null;
287
+ case 'inline':
288
+ return c.contradiction ? INLINE_WITHHELD_TEXT : null;
289
+ case 'reference': return buildReferenceStub(c.testimony, provenance, serverId);
290
+ case 'invalid': return `[unrecognized content block: ${sanitizeLabel(c.reason, STUB_FIELD_CHARS)}]`;
291
+ }
292
+ }
package/src/mcpl/types.ts CHANGED
@@ -72,6 +72,12 @@ export interface McplImageContent {
72
72
  mimeType?: string;
73
73
  /** URI reference (present when using URI form) */
74
74
  uri?: string;
75
+ /** RFC-005 reference testimony (uri form only). */
76
+ sizeBytes?: number;
77
+ digest?: string;
78
+ expiresAt?: string;
79
+ name?: string;
80
+ disposition?: 'never' | 'ref';
75
81
  }
76
82
 
77
83
  export interface McplAudioContent {
@@ -82,12 +88,25 @@ export interface McplAudioContent {
82
88
  mimeType?: string;
83
89
  /** URI reference (present when using URI form) */
84
90
  uri?: string;
91
+ /** RFC-005 reference testimony (uri form only). */
92
+ sizeBytes?: number;
93
+ digest?: string;
94
+ expiresAt?: string;
95
+ name?: string;
96
+ disposition?: 'never' | 'ref';
85
97
  }
86
98
 
87
99
  export interface McplResourceContent {
88
100
  type: 'resource';
89
101
  /** Resource URI (e.g., "memory://facts/12345") */
90
102
  uri: string;
103
+ /** RFC-005 §3 reference testimony (all optional, all server claims). */
104
+ mimeType?: string;
105
+ sizeBytes?: number;
106
+ digest?: string;
107
+ expiresAt?: string;
108
+ name?: string;
109
+ disposition?: 'never' | 'ref';
91
110
  }
92
111
 
93
112
  // ============================================================================
@@ -355,6 +374,10 @@ export interface McplServerConfig {
355
374
  * Chronicle and changed with channel_open/channel_close.
356
375
  */
357
376
  channelSubscription?: 'auto' | 'manual' | string[];
377
+ /** RFC-005 autofetch envelope for this server: per-fetch eager ceiling and
378
+ * cumulative per-server byte budget (reference-fetcher.ts defaults apply
379
+ * when absent). */
380
+ autofetch?: { maxBytes?: number; maxTotalBytes?: number };
358
381
  }
359
382
 
360
383
  // ============================================================================
@@ -1233,4 +1256,10 @@ export interface McpToolResultContent {
1233
1256
  data?: string;
1234
1257
  mimeType?: string;
1235
1258
  uri?: string;
1259
+ /** RFC-005 §3 reference testimony (uri form only; server claims). */
1260
+ sizeBytes?: number;
1261
+ digest?: string;
1262
+ expiresAt?: string;
1263
+ name?: string;
1264
+ disposition?: 'never' | 'ref';
1236
1265
  }
@@ -8,7 +8,8 @@ export type { ApiEvent } from './api/index.js';
8
8
  export { HealthModule } from './health/index.js';
9
9
  export type { HealthModuleConfig } from './health/index.js';
10
10
 
11
- export { WorkspaceModule } from './workspace/index.js';
11
+ export { WorkspaceModule, WorkspaceReadError } from './workspace/index.js';
12
+ export type { WorkspaceReadErrorCode, WorkspaceReadStage, WorkspaceDiskReadResult, ReadFileFromDiskOptions } from './workspace/index.js';
12
13
  export type {
13
14
  WorkspaceConfig,
14
15
  MountConfig,