@animalabs/connectome-host 0.7.3 → 0.8.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/.env.example +12 -5
- package/.github/PULL_REQUEST_TEMPLATE.md +3 -2
- package/.github/workflows/changelog.yml +9 -4
- package/.github/workflows/ci.yml +5 -3
- package/.github/workflows/publish.yml +12 -6
- package/CHANGELOG.md +401 -10
- package/CONTRIBUTING.md +47 -19
- package/HEADLESS-FLEET-PLAN.md +22 -0
- package/README.md +39 -1
- package/bun.lock +27 -31
- package/changelog.d/README.md +28 -0
- package/docs/AGENT-ONBOARDING.md +1 -1
- package/docs/debug-context-api.md +2 -2
- package/docs/retrieval-traces.md +173 -0
- package/docs/webui-deployment.md +2 -1
- package/package.json +6 -6
- package/recipes/SETUP.md +11 -5
- package/recipes/TRIUMVIRATE-SETUP.md +68 -14
- package/recipes/knowledge-miner.json +0 -30
- package/recipes/mock-test.json +19 -0
- package/recipes/triumvirate.json +6 -1
- package/scripts/audit-module-optins.ts +288 -0
- package/scripts/release-changelog.ts +210 -21
- package/src/cache-keepalive-log.ts +41 -0
- package/src/commands.ts +96 -0
- package/src/framework-strategy.ts +50 -4
- package/src/gate-telemetry.ts +106 -0
- package/src/headless.ts +24 -0
- package/src/index.ts +179 -64
- package/src/mcpl-config.ts +99 -1
- package/src/modules/fleet-module.ts +60 -1
- package/src/modules/fleet-types.ts +30 -1
- package/src/modules/identity-module.ts +310 -2
- package/src/modules/instructions-module.ts +265 -0
- package/src/modules/mcpl-admin-module.ts +89 -13
- package/src/modules/retrieval-module.ts +249 -51
- package/src/modules/retrieval-trace-page.ts +254 -0
- package/src/modules/retrieval-trace.ts +904 -0
- package/src/modules/subagent-module.ts +18 -0
- package/src/modules/tts-relay-module.ts +33 -18
- package/src/modules/web-ui-module.ts +445 -894
- package/src/recipe.ts +787 -29
- package/src/retrieval-config.ts +39 -0
- package/src/strategies/frontdesk-strategy.ts +34 -125
- package/src/tui.ts +325 -54
- package/src/web/panel-data.ts +1206 -0
- package/src/web/protocol.ts +75 -10
- package/src/workspace-mounts.ts +73 -0
- package/test/audit-module-optins.test.ts +174 -0
- package/test/cache-keepalive-log.test.ts +83 -0
- package/test/conversations-recipe.test.ts +142 -0
- package/test/fleet-panel-request.test.ts +90 -0
- package/test/framework-fkm-composition.test.ts +35 -3
- package/test/framework-strategy-defaults.test.ts +41 -0
- package/test/frontdesk-strategy.test.ts +25 -37
- package/test/gate-telemetry-adapter.test.ts +84 -0
- package/test/gate-telemetry.test.ts +91 -0
- package/test/headless-panel-request.test.ts +201 -0
- package/test/identity-and-surfaces.test.ts +212 -1
- package/test/instructions-module.test.ts +258 -0
- package/test/mcpl-admin-module.test.ts +64 -0
- package/test/mcpl-agent-overlay.test.ts +51 -3
- package/test/mcpl-child-env.test.ts +64 -0
- package/test/mock-headless-child.ts +14 -0
- package/test/nudge-command.test.ts +47 -0
- package/test/recipe-cache-keepalive.test.ts +59 -0
- package/test/recipe-compression-fallback.test.ts +19 -0
- package/test/recipe-hybrid-prose-routing.test.ts +12 -0
- package/test/recipe-instructions.test.ts +176 -0
- package/test/recipe-kv-unified.test.ts +87 -0
- package/test/recipe-mcp-source.test.ts +54 -0
- package/test/recipe-openai-compatible.test.ts +54 -0
- package/test/recipe-path-resolution.test.ts +19 -8
- package/test/recipe-provider.test.ts +14 -0
- package/test/recipe-save-unresolved.test.ts +244 -0
- package/test/recipe-source-only.test.ts +38 -0
- package/test/release-changelog.test.ts +202 -0
- package/test/retrieval-auth-loopback.test.ts +49 -0
- package/test/retrieval-config.test.ts +74 -0
- package/test/retrieval-module.test.ts +821 -0
- package/test/subagent-prose-routing.test.ts +109 -0
- package/test/tui-format.test.ts +106 -0
- package/test/web-ui-context-coverage.test.ts +1 -1
- package/test/web-ui-module.test.ts +189 -3
- package/test/web-ui-observers.test.ts +8 -5
- package/test/web-ui-protocol.test.ts +0 -0
- package/test/workspace-mounts.test.ts +68 -0
- package/web/src/App.tsx +160 -44
- package/web/src/Context.tsx +35 -8
- package/web/src/ContextDocument.tsx +20 -5
- package/web/src/Files.tsx +2 -8
- package/web/src/Health.tsx +61 -1
- package/web/src/Lessons.tsx +2 -38
- package/web/src/Mcpl.tsx +80 -14
- package/web/src/Pins.tsx +5 -0
- package/web/src/Settings.tsx +5 -0
- package/web/vite.config.ts +8 -2
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
createPublicKey,
|
|
34
34
|
generateKeyPairSync,
|
|
35
35
|
sign as cryptoSign,
|
|
36
|
+
createHash,
|
|
36
37
|
type KeyObject,
|
|
37
38
|
} from 'node:crypto';
|
|
38
39
|
import type {
|
|
@@ -41,6 +42,7 @@ import type {
|
|
|
41
42
|
ToolCall,
|
|
42
43
|
ToolResult,
|
|
43
44
|
ToolDefinition,
|
|
45
|
+
WorkspaceModule,
|
|
44
46
|
} from '@animalabs/agent-framework';
|
|
45
47
|
|
|
46
48
|
export interface IdentityModuleConfig {
|
|
@@ -51,10 +53,57 @@ export interface IdentityModuleConfig {
|
|
|
51
53
|
home: string;
|
|
52
54
|
/** Audience assumed when none is named. */
|
|
53
55
|
defaultAudience?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Services reachable through the `request` utility: audience name → API
|
|
58
|
+
* base URL. The allowlist IS the security boundary — the host only ever
|
|
59
|
+
* attaches standing access to these bases, so the utility can't be
|
|
60
|
+
* steered at arbitrary URLs. Merged over built-in defaults for the
|
|
61
|
+
* animalabs services.
|
|
62
|
+
*/
|
|
63
|
+
services?: Record<string, string>;
|
|
54
64
|
/** Injectable for tests. */
|
|
55
65
|
fetchImpl?: typeof fetch;
|
|
56
66
|
}
|
|
57
67
|
|
|
68
|
+
/** Known services when anchored at the animalabs home — a recipe can extend
|
|
69
|
+
* or override via `services`. */
|
|
70
|
+
const DEFAULT_SERVICES: Record<string, string> = {
|
|
71
|
+
orrery: 'https://orrery.animalabs.ai',
|
|
72
|
+
eidoverse: 'https://eidoverse.animalabs.ai',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** How long a fetched service directory is trusted before a background-ish
|
|
76
|
+
* refresh. Short by design: "a service changed" should reach residents in
|
|
77
|
+
* about a minute, and an unknown name forces a refresh immediately anyway. */
|
|
78
|
+
const SERVICES_TTL_MS = 60_000;
|
|
79
|
+
const SERVICES_TIMEOUT_MS = 5_000;
|
|
80
|
+
|
|
81
|
+
const REQUEST_BODY_MAX = 256 * 1024;
|
|
82
|
+
/** Uploads are bytes the host streams from a workspace file, not text the
|
|
83
|
+
* model wrote, so the small JSON-body cap would be the wrong limit: a track
|
|
84
|
+
* or a render is legitimately megabytes. Still bounded — one call should not
|
|
85
|
+
* be able to push an unbounded file at a service. */
|
|
86
|
+
const UPLOAD_BODY_MAX = 64 * 1024 * 1024;
|
|
87
|
+
|
|
88
|
+
/** Enough to label the common uploads honestly; anything else is
|
|
89
|
+
* application/octet-stream unless the caller says otherwise. */
|
|
90
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
91
|
+
mp3: 'audio/mpeg', wav: 'audio/wav', flac: 'audio/flac', ogg: 'audio/ogg',
|
|
92
|
+
opus: 'audio/opus', m4a: 'audio/mp4', aac: 'audio/aac',
|
|
93
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
94
|
+
webp: 'image/webp', svg: 'image/svg+xml',
|
|
95
|
+
mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
|
|
96
|
+
json: 'application/json', txt: 'text/plain', md: 'text/markdown',
|
|
97
|
+
pdf: 'application/pdf', zip: 'application/zip',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function guessContentType(path: string): string {
|
|
101
|
+
const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase();
|
|
102
|
+
return CONTENT_TYPES[ext] ?? 'application/octet-stream';
|
|
103
|
+
}
|
|
104
|
+
const RESPONSE_INLINE_MAX = 24 * 1024;
|
|
105
|
+
const RESPONSE_BODY_MAX = 64 * 1024 * 1024;
|
|
106
|
+
|
|
58
107
|
/** Persisted beside the key after a successful registration. */
|
|
59
108
|
interface IdentityRecord {
|
|
60
109
|
sub: string;
|
|
@@ -74,12 +123,19 @@ export class IdentityModule implements Module {
|
|
|
74
123
|
readonly name = 'identity';
|
|
75
124
|
private readonly recordPath: string;
|
|
76
125
|
|
|
126
|
+
/** Last good service directory from the home node, and when we got it. */
|
|
127
|
+
private servicesFromHome: Record<string, string> = {};
|
|
128
|
+
private servicesFetchedAt = 0;
|
|
129
|
+
private servicesInflight: Promise<void> | null = null;
|
|
130
|
+
|
|
77
131
|
constructor(private readonly config: IdentityModuleConfig) {
|
|
78
132
|
this.recordPath = config.keyPath.replace(/\.pem$/, '') + '.json';
|
|
79
133
|
}
|
|
80
134
|
|
|
81
|
-
|
|
82
|
-
|
|
135
|
+
private ctx: ModuleContext | null = null;
|
|
136
|
+
|
|
137
|
+
async start(ctx: ModuleContext): Promise<void> { this.ctx = ctx; }
|
|
138
|
+
async stop(): Promise<void> { this.ctx = null; }
|
|
83
139
|
|
|
84
140
|
getTools(): ToolDefinition[] {
|
|
85
141
|
return []; // utilities-only, by design — see module header
|
|
@@ -95,6 +151,30 @@ export class IdentityModule implements Module {
|
|
|
95
151
|
'managed by the host from this — you never handle credentials yourself.',
|
|
96
152
|
inputSchema: { type: 'object', properties: {} },
|
|
97
153
|
},
|
|
154
|
+
{
|
|
155
|
+
name: 'request',
|
|
156
|
+
description:
|
|
157
|
+
'Call a connected service’s API (e.g. "orrery") with your standing access ' +
|
|
158
|
+
'attached by the host — nothing for you to obtain, renew, or handle; renewal ' +
|
|
159
|
+
'is automatic. Give the service name and a path; returns {status, body}. ' +
|
|
160
|
+
'For binary responses, pass saveAs with a workspace path (e.g. files/artifacts/image.png). ' +
|
|
161
|
+
'To send a file — audio, images, anything large — pass fromFile with a workspace ' +
|
|
162
|
+
'path instead of body; the host streams the bytes, so the file never has to pass ' +
|
|
163
|
+
'through what you are writing.',
|
|
164
|
+
inputSchema: {
|
|
165
|
+
type: 'object',
|
|
166
|
+
properties: {
|
|
167
|
+
service: { type: 'string', description: 'Service name, e.g. "orrery". Unknown names list what is available.' },
|
|
168
|
+
path: { type: 'string', description: 'API path starting with "/", e.g. "/api/ops".' },
|
|
169
|
+
method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE'], description: 'Default GET.' },
|
|
170
|
+
body: { type: 'object', description: 'JSON body for POST/PUT.' },
|
|
171
|
+
fromFile: { type: 'string', description: 'Workspace path whose raw bytes become the request body, e.g. files/music/track.mp3. Use instead of body for uploads; not combinable with it.' },
|
|
172
|
+
contentType: { type: 'string', description: 'Content-Type for fromFile uploads. Inferred from the file extension when omitted.' },
|
|
173
|
+
saveAs: { type: 'string', description: 'Optional workspace path for the raw response bytes, e.g. files/artifacts/image.png. Required to retrieve binary bodies without loss.' },
|
|
174
|
+
},
|
|
175
|
+
required: ['service', 'path'],
|
|
176
|
+
},
|
|
177
|
+
},
|
|
98
178
|
{
|
|
99
179
|
name: 'accept_invite',
|
|
100
180
|
description:
|
|
@@ -120,6 +200,8 @@ export class IdentityModule implements Module {
|
|
|
120
200
|
return this.status();
|
|
121
201
|
case 'accept_invite':
|
|
122
202
|
return await this.acceptInvite(call.input as { invite?: unknown; name?: unknown });
|
|
203
|
+
case 'request':
|
|
204
|
+
return await this.request(call.input as { service?: unknown; path?: unknown; method?: unknown; body?: unknown; saveAs?: unknown });
|
|
123
205
|
default:
|
|
124
206
|
return fail(`Unknown identity utility: ${call.name}`);
|
|
125
207
|
}
|
|
@@ -239,6 +321,232 @@ export class IdentityModule implements Module {
|
|
|
239
321
|
);
|
|
240
322
|
}
|
|
241
323
|
|
|
324
|
+
/** Audience → API base, resolved from the home node's `/services` directory
|
|
325
|
+
* at runtime. The archipelago's service list belongs to the trust anchor,
|
|
326
|
+
* not to a constant compiled into every host: a service added at the home
|
|
327
|
+
* node reaches every resident without a restart or a recipe edit.
|
|
328
|
+
*
|
|
329
|
+
* Layering, most specific last: fetched directory → built-in defaults for
|
|
330
|
+
* anything the directory omits → recipe `services` (explicit local config
|
|
331
|
+
* still wins, for testing and for hosts anchored elsewhere).
|
|
332
|
+
*
|
|
333
|
+
* Never throws: a home node that is down, slow, or rate-limiting leaves the
|
|
334
|
+
* last good directory in place (or the built-in defaults on a cold start),
|
|
335
|
+
* so losing discovery degrades to today's behaviour rather than to an
|
|
336
|
+
* outage. */
|
|
337
|
+
private async resolveServices(opts: { force?: boolean } = {}): Promise<Record<string, string>> {
|
|
338
|
+
const now = Date.now();
|
|
339
|
+
const fresh = this.servicesFetchedAt > 0 && now - this.servicesFetchedAt < SERVICES_TTL_MS;
|
|
340
|
+
if (!opts.force && fresh) return this.layerServices();
|
|
341
|
+
// Collapse concurrent refreshes onto one request.
|
|
342
|
+
if (!this.servicesInflight) {
|
|
343
|
+
const f = this.config.fetchImpl ?? fetch;
|
|
344
|
+
this.servicesInflight = (async () => {
|
|
345
|
+
try {
|
|
346
|
+
const res = await f(`https://${this.config.home}/services`, {
|
|
347
|
+
method: 'GET',
|
|
348
|
+
headers: { accept: 'application/json' },
|
|
349
|
+
signal: AbortSignal.timeout(SERVICES_TIMEOUT_MS),
|
|
350
|
+
});
|
|
351
|
+
if (!res.ok) throw new Error(`status ${res.status}`);
|
|
352
|
+
const json = (await res.json()) as { services?: Record<string, unknown> };
|
|
353
|
+
const map: Record<string, string> = {};
|
|
354
|
+
for (const [name, base] of Object.entries(json.services ?? {})) {
|
|
355
|
+
if (typeof base === 'string' && /^https:\/\//.test(base)) map[name] = base;
|
|
356
|
+
}
|
|
357
|
+
this.servicesFromHome = map;
|
|
358
|
+
this.servicesFetchedAt = Date.now();
|
|
359
|
+
} catch (err) {
|
|
360
|
+
// Keep whatever we had; note it once per failure for the operator.
|
|
361
|
+
console.error(
|
|
362
|
+
`[identity] service directory refresh failed (${this.config.home}): ${err instanceof Error ? err.message : String(err)}`,
|
|
363
|
+
);
|
|
364
|
+
} finally {
|
|
365
|
+
this.servicesInflight = null;
|
|
366
|
+
}
|
|
367
|
+
})();
|
|
368
|
+
}
|
|
369
|
+
await this.servicesInflight;
|
|
370
|
+
return this.layerServices();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private layerServices(): Record<string, string> {
|
|
374
|
+
return { ...DEFAULT_SERVICES, ...this.servicesFromHome, ...this.config.services };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** The agent-facing HTTP seam for non-MCPL services (Orrery et al.): the
|
|
378
|
+
* host resolves the base URL from the directory, fetches fresh access,
|
|
379
|
+
* attaches it, and returns only {status, body}. The credential exists
|
|
380
|
+
* for the duration of one fetch, outside model context. */
|
|
381
|
+
private async request(input: {
|
|
382
|
+
service?: unknown;
|
|
383
|
+
path?: unknown;
|
|
384
|
+
method?: unknown;
|
|
385
|
+
body?: unknown;
|
|
386
|
+
fromFile?: unknown;
|
|
387
|
+
contentType?: unknown;
|
|
388
|
+
saveAs?: unknown;
|
|
389
|
+
}): Promise<ToolResult> {
|
|
390
|
+
const service = typeof input.service === 'string' ? input.service : '';
|
|
391
|
+
let services = await this.resolveServices();
|
|
392
|
+
let base = services[service];
|
|
393
|
+
if (!base && service) {
|
|
394
|
+
// A name we don't know yet is the likeliest moment for the directory to
|
|
395
|
+
// have moved under us — a service that went live since our last refresh.
|
|
396
|
+
// Re-ask once before refusing, so a new archipelago service is usable
|
|
397
|
+
// immediately rather than after a cache expiry.
|
|
398
|
+
services = await this.resolveServices({ force: true });
|
|
399
|
+
base = services[service];
|
|
400
|
+
}
|
|
401
|
+
if (!base) {
|
|
402
|
+
return fail(`Unknown service "${service}". Available: ${Object.keys(services).join(', ')}`);
|
|
403
|
+
}
|
|
404
|
+
if (typeof input.path !== 'string' || !input.path.startsWith('/')) {
|
|
405
|
+
return fail('`path` must be a string starting with "/"');
|
|
406
|
+
}
|
|
407
|
+
const method = typeof input.method === 'string' ? input.method.toUpperCase() : 'GET';
|
|
408
|
+
if (!['GET', 'POST', 'PUT', 'DELETE'].includes(method)) return fail(`unsupported method ${method}`);
|
|
409
|
+
let bodyStr: string | undefined;
|
|
410
|
+
if (input.body !== undefined && method !== 'GET') {
|
|
411
|
+
bodyStr = typeof input.body === 'string' ? input.body : JSON.stringify(input.body);
|
|
412
|
+
if (bodyStr.length > REQUEST_BODY_MAX) return fail(`body too large (${bodyStr.length} > ${REQUEST_BODY_MAX})`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Uploads: the agent names a workspace file, the host sends its bytes. The
|
|
416
|
+
// mirror of saveAs — a file too big to write into a turn is exactly the
|
|
417
|
+
// case that needs this, so the bytes never enter model context, and the
|
|
418
|
+
// small JSON body cap does not apply to them.
|
|
419
|
+
let upload: { bytes: Buffer; contentType: string; path: string } | undefined;
|
|
420
|
+
if (typeof input.fromFile === 'string' && input.fromFile.length > 0) {
|
|
421
|
+
if (bodyStr !== undefined) return fail('pass either `body` or `fromFile`, not both');
|
|
422
|
+
if (method === 'GET') return fail('`fromFile` needs a method with a body (POST or PUT)');
|
|
423
|
+
const workspace = this.ctx?.getModule<WorkspaceModule>('workspace');
|
|
424
|
+
if (!workspace) return fail('identity request: workspace module is not available for fromFile');
|
|
425
|
+
// Disk first, store second. An upload is egress: reading it through the
|
|
426
|
+
// append-only store would retain every byte forever and refuse anything
|
|
427
|
+
// binary or over the mount's maxFileSize — which is most media. Disk
|
|
428
|
+
// has neither problem. The store remains the fallback for a file the
|
|
429
|
+
// resident wrote through the workspace and never materialized.
|
|
430
|
+
const diskRead = (workspace as WorkspaceModule & {
|
|
431
|
+
readBinaryFromDisk?: (
|
|
432
|
+
path: string,
|
|
433
|
+
opts?: { maxBytes?: number },
|
|
434
|
+
) => Promise<{ data: Buffer; absolutePath: string } | { error: string }>;
|
|
435
|
+
}).readBinaryFromDisk;
|
|
436
|
+
const fromDisk = diskRead
|
|
437
|
+
? await diskRead.call(workspace, input.fromFile, { maxBytes: UPLOAD_BODY_MAX })
|
|
438
|
+
: { error: 'this host has no disk read path' };
|
|
439
|
+
let bytes: Buffer;
|
|
440
|
+
if (!('error' in fromDisk)) {
|
|
441
|
+
bytes = fromDisk.data;
|
|
442
|
+
} else {
|
|
443
|
+
const fromStore = await workspace.readBinary(input.fromFile);
|
|
444
|
+
if ('error' in fromStore) {
|
|
445
|
+
return fail(
|
|
446
|
+
`identity request: could not read ${input.fromFile}: ${fromDisk.error}; ${fromStore.error}`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
bytes = fromStore.data;
|
|
450
|
+
}
|
|
451
|
+
if (bytes.byteLength > UPLOAD_BODY_MAX) {
|
|
452
|
+
return fail(`file too large (${bytes.byteLength} > ${UPLOAD_BODY_MAX})`);
|
|
453
|
+
}
|
|
454
|
+
upload = {
|
|
455
|
+
bytes,
|
|
456
|
+
contentType:
|
|
457
|
+
typeof input.contentType === 'string' && input.contentType
|
|
458
|
+
? input.contentType
|
|
459
|
+
: guessContentType(input.fromFile),
|
|
460
|
+
path: input.fromFile,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
let access: string;
|
|
465
|
+
try {
|
|
466
|
+
access = await this.accessFor(service); // service name == audience name
|
|
467
|
+
} catch (err) {
|
|
468
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
469
|
+
}
|
|
470
|
+
const f = this.config.fetchImpl ?? fetch;
|
|
471
|
+
try {
|
|
472
|
+
const res = await f(`${base}${input.path}`, {
|
|
473
|
+
method,
|
|
474
|
+
headers: {
|
|
475
|
+
authorization: `Bearer ${access}`,
|
|
476
|
+
...(bodyStr !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
477
|
+
...(upload ? { 'content-type': upload.contentType } : {}),
|
|
478
|
+
},
|
|
479
|
+
...(bodyStr !== undefined ? { body: bodyStr } : {}),
|
|
480
|
+
// Uint8Array view, not the Buffer itself: Buffer is not a BodyInit.
|
|
481
|
+
...(upload ? { body: new Uint8Array(upload.bytes) } : {}),
|
|
482
|
+
});
|
|
483
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
484
|
+
if (bytes.byteLength > RESPONSE_BODY_MAX) {
|
|
485
|
+
return fail(`response too large (${bytes.byteLength} > ${RESPONSE_BODY_MAX})`);
|
|
486
|
+
}
|
|
487
|
+
const declaredType = res.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() || '';
|
|
488
|
+
const contentType = declaredType || 'application/octet-stream';
|
|
489
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
490
|
+
|
|
491
|
+
if (typeof input.saveAs === 'string' && input.saveAs.length > 0) {
|
|
492
|
+
const workspace = this.ctx?.getModule<WorkspaceModule>('workspace');
|
|
493
|
+
if (!workspace) return fail('identity request: workspace module is not available for saveAs');
|
|
494
|
+
const written = await workspace.writeBinary(input.saveAs, bytes, contentType);
|
|
495
|
+
if (!written.success) return fail(`identity request: could not save response: ${written.error ?? 'unknown'}`);
|
|
496
|
+
return ok({
|
|
497
|
+
status: res.status,
|
|
498
|
+
saved: { path: input.saveAs, size: bytes.byteLength, contentType, sha256 },
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const text = bytes.toString('utf8');
|
|
503
|
+
let parsed: unknown;
|
|
504
|
+
let parsedJson = false;
|
|
505
|
+
try {
|
|
506
|
+
parsed = JSON.parse(text);
|
|
507
|
+
parsedJson = true;
|
|
508
|
+
} catch {
|
|
509
|
+
/* not JSON */
|
|
510
|
+
}
|
|
511
|
+
const textual = declaredType.startsWith('text/')
|
|
512
|
+
|| declaredType === 'application/json'
|
|
513
|
+
|| declaredType.endsWith('+json')
|
|
514
|
+
|| declaredType === 'application/xml'
|
|
515
|
+
|| declaredType.endsWith('+xml')
|
|
516
|
+
// Some tiny internal/fake services omit content-type on JSON. A full
|
|
517
|
+
// successful parse is a safer fallback than treating valid JSON as
|
|
518
|
+
// opaque bytes; arbitrary binary almost never parses as one JSON value.
|
|
519
|
+
|| (!declaredType && parsedJson);
|
|
520
|
+
if (!textual) {
|
|
521
|
+
return ok({
|
|
522
|
+
status: res.status,
|
|
523
|
+
body: null,
|
|
524
|
+
binary: {
|
|
525
|
+
size: bytes.byteLength, contentType, sha256,
|
|
526
|
+
note: 'Binary response omitted from text context; repeat the request with saveAs to write it byte-exactly to a workspace mount.',
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
let body: unknown = parsedJson ? parsed : text;
|
|
532
|
+
const raw = typeof body === 'string' ? body : JSON.stringify(body);
|
|
533
|
+
if (raw.length > RESPONSE_INLINE_MAX) {
|
|
534
|
+
body = `${raw.slice(0, RESPONSE_INLINE_MAX)}… [truncated ${raw.length - RESPONSE_INLINE_MAX} chars]`;
|
|
535
|
+
}
|
|
536
|
+
return ok({
|
|
537
|
+
status: res.status,
|
|
538
|
+
body,
|
|
539
|
+
// A receipt for what left the house, so an upload is verifiable from
|
|
540
|
+
// the turn that made it without re-reading the file.
|
|
541
|
+
...(upload
|
|
542
|
+
? { sent: { path: upload.path, size: upload.bytes.byteLength, contentType: upload.contentType } }
|
|
543
|
+
: {}),
|
|
544
|
+
});
|
|
545
|
+
} catch (err) {
|
|
546
|
+
return fail(`${service} request failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
242
550
|
private async acceptInvite(input: { invite?: unknown; name?: unknown }): Promise<ToolResult> {
|
|
243
551
|
if (typeof input.invite !== 'string' || typeof input.name !== 'string') {
|
|
244
552
|
return fail('accept_invite needs { invite, name }');
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InstructionsModule — injects a shared, living instructions document into
|
|
3
|
+
* every agent's context on every turn.
|
|
4
|
+
*
|
|
5
|
+
* The document is a CLAUDE.md analogue maintained in a workspace mount:
|
|
6
|
+
* operators (or the agents themselves, on a read-write mount) edit one file,
|
|
7
|
+
* and the current content reaches the resident agent AND every ephemeral
|
|
8
|
+
* subagent via the framework's gatherContext hook before each inference.
|
|
9
|
+
* Nothing is persisted to Chronicle — position 'system' injections are
|
|
10
|
+
* per-turn overlays, so edits take effect on the next turn and stale copies
|
|
11
|
+
* never accumulate in history.
|
|
12
|
+
*
|
|
13
|
+
* Fail-open by design: a missing mount, missing file, or any read error
|
|
14
|
+
* yields no injection (never a blocked inference), with a warning logged
|
|
15
|
+
* once per distinct error rather than every turn.
|
|
16
|
+
*
|
|
17
|
+
* Requires the workspace module: the configured path is a mount-prefixed
|
|
18
|
+
* workspace path ("<mountName>/<relativePath>") resolved through
|
|
19
|
+
* WorkspaceModule.resolveAbsolutePath, so mount scoping and the
|
|
20
|
+
* path-traversal guard apply. validateRecipe enforces the pairing.
|
|
21
|
+
*
|
|
22
|
+
* Symlink policy: resolveAbsolutePath's traversal guard is lexical, and a
|
|
23
|
+
* symlink inside the mount pointing outside it would otherwise smuggle
|
|
24
|
+
* arbitrary host-readable files into the trusted instructions block. Before
|
|
25
|
+
* reading, both the mount root and the resolved file are realpath'd and the
|
|
26
|
+
* file must remain inside the root — the same guard WorkspaceModule applies
|
|
27
|
+
* to its own image reads. (The right long-term home for this is a safe-read
|
|
28
|
+
* API on WorkspaceModule itself; agent-framework is a separately released
|
|
29
|
+
* package, so this module enforces the policy locally until one exists.)
|
|
30
|
+
*
|
|
31
|
+
* Reads are bounded: at most maxBytes is ever loaded (the file is read
|
|
32
|
+
* through a handle, not readFile), so an oversized or growing mounted file
|
|
33
|
+
* cannot balloon memory past the configured cap.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { promises as fs } from 'node:fs';
|
|
37
|
+
import { sep } from 'node:path';
|
|
38
|
+
import type {
|
|
39
|
+
Module,
|
|
40
|
+
ModuleContext,
|
|
41
|
+
ProcessEvent,
|
|
42
|
+
ProcessState,
|
|
43
|
+
EventResponse,
|
|
44
|
+
ToolDefinition,
|
|
45
|
+
ToolCall,
|
|
46
|
+
ToolResult,
|
|
47
|
+
} from '@animalabs/agent-framework';
|
|
48
|
+
import type { ContextInjection } from '@animalabs/context-manager';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The slice of WorkspaceModule this module depends on. Structural so tests
|
|
52
|
+
* can substitute a stub resolver; production wiring passes the real
|
|
53
|
+
* WorkspaceModule (which satisfies this shape).
|
|
54
|
+
*/
|
|
55
|
+
export interface WorkspacePathResolver {
|
|
56
|
+
resolveAbsolutePath(mountPrefixedPath: string): string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface InstructionsModuleConfig {
|
|
60
|
+
/** Workspace path "<mountName>/<relativePath>". Default "instructions/AGENTS.md". */
|
|
61
|
+
path?: string;
|
|
62
|
+
/** Heading line prepended to the injected block. */
|
|
63
|
+
header?: string;
|
|
64
|
+
/** Truncate content beyond this many bytes (with an explicit marker). Default 32768. */
|
|
65
|
+
maxBytes?: number;
|
|
66
|
+
/** Where the block lands in the compiled context. Default 'system'. */
|
|
67
|
+
position?: 'system' | 'beforeUser' | 'afterUser';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const DEFAULT_INSTRUCTIONS_PATH = 'instructions/AGENTS.md';
|
|
71
|
+
export const DEFAULT_INSTRUCTIONS_HEADER =
|
|
72
|
+
'# Shared operating instructions (live document)';
|
|
73
|
+
export const DEFAULT_INSTRUCTIONS_MAX_BYTES = 32768;
|
|
74
|
+
|
|
75
|
+
export class InstructionsModule implements Module {
|
|
76
|
+
readonly name = 'instructions';
|
|
77
|
+
|
|
78
|
+
// gatherContext is a stat + (on change) one file read — well under 2s.
|
|
79
|
+
// A modest explicit budget keeps a wedged filesystem from eating the
|
|
80
|
+
// registry-wide 15s default before inference proceeds without us.
|
|
81
|
+
readonly contextTimeoutMs = 2000;
|
|
82
|
+
|
|
83
|
+
private readonly path: string;
|
|
84
|
+
private readonly header: string;
|
|
85
|
+
private readonly maxBytes: number;
|
|
86
|
+
private readonly position: 'system' | 'beforeUser' | 'afterUser';
|
|
87
|
+
|
|
88
|
+
private workspace: WorkspacePathResolver | null = null;
|
|
89
|
+
|
|
90
|
+
/** Cache keyed by (realpath, mtimeMs, size) — reread only when the file
|
|
91
|
+
* changes. The realpath in the key covers a symlink retargeted between
|
|
92
|
+
* turns to a different in-mount file with identical stat numbers. */
|
|
93
|
+
private cached: {
|
|
94
|
+
realFile: string;
|
|
95
|
+
mtimeMs: number;
|
|
96
|
+
size: number;
|
|
97
|
+
injections: ContextInjection[];
|
|
98
|
+
} | null = null;
|
|
99
|
+
|
|
100
|
+
/** Error messages already warned about — fail-open must not spam per turn. */
|
|
101
|
+
private warned = new Set<string>();
|
|
102
|
+
|
|
103
|
+
constructor(config: InstructionsModuleConfig = {}) {
|
|
104
|
+
this.path = config.path ?? DEFAULT_INSTRUCTIONS_PATH;
|
|
105
|
+
this.header = config.header ?? DEFAULT_INSTRUCTIONS_HEADER;
|
|
106
|
+
this.maxBytes = config.maxBytes ?? DEFAULT_INSTRUCTIONS_MAX_BYTES;
|
|
107
|
+
this.position = config.position ?? 'system';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Peer injection (same pattern as setFramework/setIdentity elsewhere). */
|
|
111
|
+
setWorkspace(workspace: WorkspacePathResolver): void {
|
|
112
|
+
this.workspace = workspace;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async start(_ctx: ModuleContext): Promise<void> {}
|
|
116
|
+
|
|
117
|
+
async stop(): Promise<void> {
|
|
118
|
+
this.cached = null;
|
|
119
|
+
this.warned.clear();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
getTools(): ToolDefinition[] {
|
|
123
|
+
// Passive module — no tools, only gatherContext. The file itself is
|
|
124
|
+
// read/edited through the workspace module's own tools.
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async handleToolCall(call: ToolCall): Promise<ToolResult> {
|
|
129
|
+
return {
|
|
130
|
+
success: false,
|
|
131
|
+
error: `InstructionsModule has no tool "${call.name}"`,
|
|
132
|
+
isError: true,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async onProcess(_event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
|
|
137
|
+
return {};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Same injection for every agent — resident and ephemeral subagents alike
|
|
142
|
+
* share the one living document (that is the point of the module).
|
|
143
|
+
*/
|
|
144
|
+
async gatherContext(_agentName: string): Promise<ContextInjection[]> {
|
|
145
|
+
if (!this.workspace) {
|
|
146
|
+
this.warnOnce('workspace module not wired — no instructions injected');
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const absPath = this.workspace.resolveAbsolutePath(this.path);
|
|
151
|
+
if (!absPath) {
|
|
152
|
+
this.warnOnce(
|
|
153
|
+
`cannot resolve "${this.path}" — unknown mount or path escapes it; no instructions injected`,
|
|
154
|
+
);
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
// Mount root, via the same resolver (a bare mount name resolves to the
|
|
158
|
+
// root). Needed for the realpath containment check below.
|
|
159
|
+
const mountName = this.path.slice(0, this.path.indexOf('/'));
|
|
160
|
+
const mountRoot = this.workspace.resolveAbsolutePath(mountName);
|
|
161
|
+
if (!mountRoot) {
|
|
162
|
+
this.warnOnce(
|
|
163
|
+
`cannot resolve mount "${mountName}" root; no instructions injected`,
|
|
164
|
+
);
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
// Symlink guard: resolveAbsolutePath's containment is lexical only, so
|
|
170
|
+
// realpath both ends and require the real file to still live under the
|
|
171
|
+
// real mount root. Runs before the cache consult — a symlink swapped
|
|
172
|
+
// since last turn must never serve (or seed) cached content.
|
|
173
|
+
const realRoot = await fs.realpath(mountRoot);
|
|
174
|
+
const realFile = await fs.realpath(absPath);
|
|
175
|
+
if (realFile !== realRoot && !realFile.startsWith(realRoot + sep)) {
|
|
176
|
+
this.cached = null;
|
|
177
|
+
this.warnOnce(
|
|
178
|
+
`"${this.path}" resolves outside its mount after following symlinks; no instructions injected`,
|
|
179
|
+
);
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Handle-based read: stat and read against one open descriptor (no
|
|
184
|
+
// stat-then-read race), loading at most maxBytes regardless of file
|
|
185
|
+
// size — readFile would buffer the whole file first.
|
|
186
|
+
const handle = await fs.open(realFile, 'r');
|
|
187
|
+
try {
|
|
188
|
+
const stat = await handle.stat();
|
|
189
|
+
if (
|
|
190
|
+
this.cached &&
|
|
191
|
+
this.cached.realFile === realFile &&
|
|
192
|
+
this.cached.mtimeMs === stat.mtimeMs &&
|
|
193
|
+
this.cached.size === stat.size
|
|
194
|
+
) {
|
|
195
|
+
return this.cached.injections;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const readLen = Math.min(stat.size, this.maxBytes);
|
|
199
|
+
const buf = Buffer.alloc(readLen);
|
|
200
|
+
let filled = 0;
|
|
201
|
+
while (filled < readLen) {
|
|
202
|
+
const { bytesRead } = await handle.read(buf, filled, readLen - filled, filled);
|
|
203
|
+
if (bytesRead === 0) break; // file shrank mid-read; keep what we have
|
|
204
|
+
filled += bytesRead;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let content: string;
|
|
208
|
+
if (stat.size > this.maxBytes) {
|
|
209
|
+
// Back the cut up to a UTF-8 sequence boundary so a multibyte
|
|
210
|
+
// character split at the cap never decodes to U+FFFD right before
|
|
211
|
+
// the marker. The bytes past the cap were never read, so detect a
|
|
212
|
+
// straddle from the kept tail alone: find the last lead byte and
|
|
213
|
+
// drop the sequence iff it declares more bytes than were kept.
|
|
214
|
+
let cut = filled;
|
|
215
|
+
let lead = filled - 1;
|
|
216
|
+
let trailing = 0;
|
|
217
|
+
while (lead >= 0 && (buf[lead]! & 0xc0) === 0x80) {
|
|
218
|
+
lead--;
|
|
219
|
+
trailing++;
|
|
220
|
+
}
|
|
221
|
+
if (lead >= 0) {
|
|
222
|
+
const b = buf[lead]!;
|
|
223
|
+
const expected =
|
|
224
|
+
(b & 0x80) === 0 ? 1
|
|
225
|
+
: (b & 0xe0) === 0xc0 ? 2
|
|
226
|
+
: (b & 0xf0) === 0xe0 ? 3
|
|
227
|
+
: (b & 0xf8) === 0xf0 ? 4
|
|
228
|
+
: 1; // invalid lead — leave it; decoding was lossy anyway
|
|
229
|
+
if (expected > trailing + 1) cut = lead;
|
|
230
|
+
}
|
|
231
|
+
content =
|
|
232
|
+
buf.subarray(0, cut).toString('utf-8') +
|
|
233
|
+
`\n\n[truncated: first ${cut} of ${stat.size} bytes]`;
|
|
234
|
+
} else {
|
|
235
|
+
content = buf.subarray(0, filled).toString('utf-8');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const injections: ContextInjection[] = [
|
|
239
|
+
{
|
|
240
|
+
namespace: 'instructions',
|
|
241
|
+
position: this.position,
|
|
242
|
+
content: [{ type: 'text', text: `${this.header}\n\n${content}` }],
|
|
243
|
+
},
|
|
244
|
+
];
|
|
245
|
+
this.cached = { realFile, mtimeMs: stat.mtimeMs, size: stat.size, injections };
|
|
246
|
+
// Recovered — let a future recurrence of a previous error warn again.
|
|
247
|
+
this.warned.clear();
|
|
248
|
+
return injections;
|
|
249
|
+
} finally {
|
|
250
|
+
await handle.close();
|
|
251
|
+
}
|
|
252
|
+
} catch (error) {
|
|
253
|
+
// Fail open: missing file or any read error never blocks inference.
|
|
254
|
+
this.cached = null;
|
|
255
|
+
this.warnOnce(error instanceof Error ? error.message : String(error));
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private warnOnce(message: string): void {
|
|
261
|
+
if (this.warned.has(message)) return;
|
|
262
|
+
this.warned.add(message);
|
|
263
|
+
console.error(`InstructionsModule: ${message}`);
|
|
264
|
+
}
|
|
265
|
+
}
|