@animalabs/connectome-host 0.7.2 → 0.7.3
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/CHANGELOG.md +47 -0
- package/README.md +11 -11
- package/docs/AGENT-ONBOARDING.md +20 -1
- package/package.json +2 -2
- package/scripts/warmup-session.ts +17 -3
- package/src/codex-subscription-adapter.ts +13 -1
- package/src/framework-agent-config.ts +59 -4
- package/src/framework-strategy.ts +21 -0
- package/src/index.ts +86 -29
- package/src/logging-adapter.ts +13 -2
- package/src/mcpl-config.ts +8 -0
- package/src/modules/identity-module.ts +274 -0
- package/src/modules/mcpl-admin-module.ts +45 -1
- package/src/modules/observers-module.ts +12 -0
- package/src/modules/retrieval-module.ts +5 -1
- package/src/modules/settings-module.ts +28 -2
- package/src/modules/subscription-gc-module.ts +54 -1
- package/src/recipe.ts +85 -11
- package/test/bedrock-prompt-caching.test.ts +170 -0
- package/test/framework-strategy-defaults.test.ts +88 -0
- package/test/identity-and-surfaces.test.ts +157 -0
- package/test/subscription-gc-module.test.ts +152 -0
- package/web/bun.lock +345 -0
package/src/recipe.ts
CHANGED
|
@@ -71,16 +71,19 @@ export interface RecipeStrategy {
|
|
|
71
71
|
/** kv-stable: quality-gap override threshold (§13.4). Default 0.35. */
|
|
72
72
|
kvStableQualityGapRatio?: number;
|
|
73
73
|
compressionSlackRatio?: number;
|
|
74
|
+
/** Adaptive-resolution fold planner. The host defaults this to 'kv-stable'
|
|
75
|
+
* (cache-stable compile plans; see buildFrameworkStrategy) — set explicitly
|
|
76
|
+
* only to opt into the legacy planners. */
|
|
74
77
|
foldingStrategy?: 'flat-profile' | 'oldest-first' | 'kv-stable';
|
|
75
78
|
speculativeProduction?: boolean;
|
|
76
79
|
/** L1 production holdback: keep the newest N closed chunks out of the
|
|
77
80
|
* speculative compression queue (default 1); demand still overrides. */
|
|
78
81
|
l1HoldbackChunks?: number;
|
|
79
|
-
// Self-voice / compression framing.
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
82
|
+
// Self-voice / compression framing. The host defaults summaryParticipant to
|
|
83
|
+
// `agent.name` (buildFrameworkStrategy), so self-recollections speak as the
|
|
84
|
+
// agent itself. Set explicitly only to voice summaries as someone else —
|
|
85
|
+
// a mismatched participant surfaces in the compiled prompt as another voice
|
|
86
|
+
// speaking in first person about the agent.
|
|
84
87
|
summaryParticipant?: string;
|
|
85
88
|
summarySystemPrompt?: string;
|
|
86
89
|
summaryUserPrompt?: string;
|
|
@@ -121,8 +124,18 @@ export interface RecipeAgent {
|
|
|
121
124
|
* ContextManager default (100k) applies. Raise for large-context models. */
|
|
122
125
|
contextBudgetTokens?: number;
|
|
123
126
|
/** Prompt-cache TTL ('5m' | '1h') forwarded to the provider. Defaults to
|
|
124
|
-
* '1h'; set '5m' explicitly for high-frequency, sub-5-minute workloads.
|
|
127
|
+
* '1h'; set '5m' explicitly for high-frequency, sub-5-minute workloads.
|
|
128
|
+
* Not forwarded on bedrock — that transport only has the default 5m
|
|
129
|
+
* cache and rejects the ttl field. */
|
|
125
130
|
cacheTtl?: '5m' | '1h';
|
|
131
|
+
/**
|
|
132
|
+
* Explicit prompt-caching override. Unset means provider-appropriate
|
|
133
|
+
* default: on for everything except bedrock models that predate caching
|
|
134
|
+
* support there (see bedrockModelSupportsPromptCaching). Set false if a
|
|
135
|
+
* transport/account rejects cache_control markers — AWS's "your request
|
|
136
|
+
* did not allow prompt caching" can also be account/region-dependent.
|
|
137
|
+
*/
|
|
138
|
+
promptCaching?: boolean;
|
|
126
139
|
/**
|
|
127
140
|
* Same-round routing policy for ordinary text emitted beside think().
|
|
128
141
|
* Omitted preserves the compatibility carry-forward in Agent Framework.
|
|
@@ -150,6 +163,13 @@ export interface RecipeAgent {
|
|
|
150
163
|
thinking?: {
|
|
151
164
|
enabled: boolean;
|
|
152
165
|
budgetTokens?: number;
|
|
166
|
+
/** 'enabled' (explicit budget, legacy) or 'adaptive' (model-managed;
|
|
167
|
+
* required by opus-4-7+ / fable-5 era models). */
|
|
168
|
+
type?: 'enabled' | 'adaptive';
|
|
169
|
+
/** 'summarized' returns readable reasoning summaries in `thinking`;
|
|
170
|
+
* 'omitted' returns empty text + signature only. Models 4.7+ default
|
|
171
|
+
* to 'omitted' server-side. */
|
|
172
|
+
display?: 'summarized' | 'omitted';
|
|
153
173
|
};
|
|
154
174
|
/** OpenAI Responses settings. Reasoning applies to both OpenAI providers;
|
|
155
175
|
* compaction and serviceTier are API-key transport settings. */
|
|
@@ -188,6 +208,12 @@ export interface RecipeMcpServer {
|
|
|
188
208
|
transport?: 'stdio' | 'websocket';
|
|
189
209
|
/** Bearer token for WebSocket auth (appended as ?token= query param). */
|
|
190
210
|
token?: string;
|
|
211
|
+
/**
|
|
212
|
+
* Name of a host-managed access grant (archipelago audience, e.g.
|
|
213
|
+
* "eidoverse"). Requires the `identity` module. The host resolves fresh
|
|
214
|
+
* credentials per dial; neither the recipe nor the agent holds one.
|
|
215
|
+
*/
|
|
216
|
+
access?: string;
|
|
191
217
|
toolPrefix?: string;
|
|
192
218
|
enabledFeatureSets?: string[];
|
|
193
219
|
disabledFeatureSets?: string[];
|
|
@@ -382,8 +408,22 @@ export interface RecipeWorkspaceMount {
|
|
|
382
408
|
}
|
|
383
409
|
|
|
384
410
|
export interface RecipeModules {
|
|
411
|
+
/**
|
|
412
|
+
* Subagent forking (spawn/fork parallel agents). OPT-IN — defaults to off
|
|
413
|
+
* and is not part of the standard recipe.
|
|
414
|
+
*/
|
|
385
415
|
subagents?: boolean | { defaultModel?: string; defaultMaxTokens?: number };
|
|
416
|
+
/**
|
|
417
|
+
* Lesson library (persistent knowledge store + lesson tools). OPT-IN —
|
|
418
|
+
* defaults to off and is not part of the standard recipe.
|
|
419
|
+
*/
|
|
386
420
|
lessons?: boolean;
|
|
421
|
+
/**
|
|
422
|
+
* Lesson retrieval-injection (requires `lessons`). OPT-IN — defaults to off
|
|
423
|
+
* and is deliberately not part of the standard recipe: it injects
|
|
424
|
+
* context-dependent content into every compile and spends two Haiku calls
|
|
425
|
+
* per turn. Enable only for agents that actually curate a lesson library.
|
|
426
|
+
*/
|
|
387
427
|
retrieval?: boolean | { model?: string; maxInjected?: number };
|
|
388
428
|
wake?: boolean | import('@animalabs/agent-framework').GateConfig;
|
|
389
429
|
workspace?: boolean | { mounts: RecipeWorkspaceMount[]; configMount?: boolean };
|
|
@@ -404,8 +444,23 @@ export interface RecipeModules {
|
|
|
404
444
|
*
|
|
405
445
|
* SECURITY: `mcpl_deploy` spawns arbitrary commands as the host user —
|
|
406
446
|
* enabling this module means trusting the agent with code execution.
|
|
447
|
+
*
|
|
448
|
+
* `{ surface: 'utilities' }` keeps the module but parks its four tools
|
|
449
|
+
* behind the framework's single `utils` meta-tool (mcpl management is
|
|
450
|
+
* rare; it needn't cost four schemas on every inference). `true` keeps
|
|
451
|
+
* the historical first-class surface.
|
|
407
452
|
*/
|
|
408
|
-
mcplAdmin?: boolean;
|
|
453
|
+
mcplAdmin?: boolean | { surface?: 'tools' | 'utilities' };
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* The agent's own archipelago-home identity (connectome docs/home-node.md):
|
|
457
|
+
* an ed25519 keypair in the data dir, enrolled at the home node via an
|
|
458
|
+
* operator invite, exchanged for fresh aid1 audience tokens on demand.
|
|
459
|
+
* Utilities-only (`utils run identity--status/enroll/token`) — costs no
|
|
460
|
+
* tool slots. `home` defaults to id.animalabs.ai; `audience` is the
|
|
461
|
+
* default for `token` calls.
|
|
462
|
+
*/
|
|
463
|
+
identity?: boolean | { home?: string; audience?: string };
|
|
409
464
|
/**
|
|
410
465
|
* Cross-process child fleet. When true (shorthand), FleetModule is attached
|
|
411
466
|
* with no pre-configured children. When an object, declares children the
|
|
@@ -485,6 +540,13 @@ export interface RecipeModules {
|
|
|
485
540
|
export interface RecipeWebUi {
|
|
486
541
|
port?: number;
|
|
487
542
|
host?: string;
|
|
543
|
+
/**
|
|
544
|
+
* Where the observer grant tools (observers--get/grant/revoke) surface:
|
|
545
|
+
* 'tools' (default, historical) or 'utilities' (behind the `utils`
|
|
546
|
+
* meta-tool — grant edits are rare). Consent semantics unchanged: the
|
|
547
|
+
* agent holds the pen either way.
|
|
548
|
+
*/
|
|
549
|
+
observersSurface?: 'tools' | 'utilities';
|
|
488
550
|
/**
|
|
489
551
|
* Basic-Auth credentials. Required whenever the bind host is non-loopback
|
|
490
552
|
* (which is the default). `${VAR}`-substitutable from .env.
|
|
@@ -651,13 +713,13 @@ export const DEFAULT_RECIPE: Recipe = {
|
|
|
651
713
|
'You are a helpful assistant. You have access to tools provided by connected MCP servers.',
|
|
652
714
|
'Use them to help the user with their tasks.',
|
|
653
715
|
'',
|
|
654
|
-
'You can
|
|
716
|
+
'You can create persistent notes and write files to `products/` as outputs of your work.',
|
|
655
717
|
].join('\n'),
|
|
656
718
|
},
|
|
657
719
|
modules: {
|
|
658
|
-
subagents
|
|
659
|
-
|
|
660
|
-
|
|
720
|
+
// subagents + lessons + retrieval deliberately omitted — all opt-in only
|
|
721
|
+
// (retrieval additionally adds per-turn context churn + Haiku costs);
|
|
722
|
+
// see the RecipeModules field docs.
|
|
661
723
|
wake: true,
|
|
662
724
|
workspace: true,
|
|
663
725
|
},
|
|
@@ -914,6 +976,12 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
914
976
|
}
|
|
915
977
|
agent.cacheTtl ??= '1h';
|
|
916
978
|
|
|
979
|
+
if (agent.promptCaching !== undefined && typeof agent.promptCaching !== 'boolean') {
|
|
980
|
+
throw new Error(
|
|
981
|
+
`Recipe agent.promptCaching must be a boolean, got ${JSON.stringify(agent.promptCaching)}.`,
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
|
|
917
985
|
if (
|
|
918
986
|
agent.sameRoundThinkTextPolicy !== undefined &&
|
|
919
987
|
agent.sameRoundThinkTextPolicy !== 'public' &&
|
|
@@ -938,6 +1006,12 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
938
1006
|
if (thinking.budgetTokens !== undefined && (typeof thinking.budgetTokens !== 'number' || thinking.budgetTokens <= 0)) {
|
|
939
1007
|
throw new Error('Recipe agent.thinking.budgetTokens must be a positive number.');
|
|
940
1008
|
}
|
|
1009
|
+
if (thinking.type !== undefined && thinking.type !== 'enabled' && thinking.type !== 'adaptive') {
|
|
1010
|
+
throw new Error('Recipe agent.thinking.type must be "enabled" or "adaptive".');
|
|
1011
|
+
}
|
|
1012
|
+
if (thinking.display !== undefined && thinking.display !== 'summarized' && thinking.display !== 'omitted') {
|
|
1013
|
+
throw new Error('Recipe agent.thinking.display must be "summarized" or "omitted".');
|
|
1014
|
+
}
|
|
941
1015
|
if (thinking.enabled === true && typeof thinking.budgetTokens === 'number' && typeof agent.maxTokens === 'number') {
|
|
942
1016
|
if (thinking.budgetTokens >= agent.maxTokens) {
|
|
943
1017
|
throw new Error(
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bedrock prompt caching is model-gated, not suppressed transport-wide
|
|
3
|
+
* (issue #35). The deny-list is the pre-GA FAMILIES (Claude v2/instant,
|
|
4
|
+
* Claude 3, 3.5 Sonnet — Bedrock's caching GA never covered them), matched
|
|
5
|
+
* at the family boundary so dated ids, bare aliases, -latest, and
|
|
6
|
+
* inference-profile forms all resolve the same. Everything currently
|
|
7
|
+
* invokable on Bedrock caches (verified by live probe 2026-07-31; the
|
|
8
|
+
* 3.5 era is EOL there). The old blanket promptCaching:false made every
|
|
9
|
+
* modern Bedrock agent re-pay its full context on every call.
|
|
10
|
+
*
|
|
11
|
+
* The recipe override must land at BOTH layers — per-agent config for
|
|
12
|
+
* agent inference AND Membrane defaultPromptCaching for internal callers
|
|
13
|
+
* (compression/merge) — on every provider, per Sol's #69 review.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import { validateRecipe } from '../src/recipe.js';
|
|
17
|
+
import {
|
|
18
|
+
buildFrameworkAgentConfig,
|
|
19
|
+
bedrockModelSupportsPromptCaching,
|
|
20
|
+
membraneCachingOverride,
|
|
21
|
+
} from '../src/framework-agent-config.js';
|
|
22
|
+
|
|
23
|
+
function recipe(agent: Record<string, unknown> = {}) {
|
|
24
|
+
return { name: 'bedrock-caching-test', agent: { systemPrompt: 'sys', ...agent } };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('bedrockModelSupportsPromptCaching', () => {
|
|
28
|
+
test('ids without GA caching support are gated off', () => {
|
|
29
|
+
for (const id of [
|
|
30
|
+
'anthropic.claude-3-opus-20240229-v1:0',
|
|
31
|
+
'anthropic.claude-3-sonnet-20240229-v1:0',
|
|
32
|
+
'anthropic.claude-3-haiku-20240307-v1:0',
|
|
33
|
+
'us.anthropic.claude-3-5-sonnet-20240620-v1:0',
|
|
34
|
+
// "3.6" — its caching was preview-only on Bedrock, dropped at GA
|
|
35
|
+
// (and the model itself is EOL there as of 7/2026).
|
|
36
|
+
'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
|
|
37
|
+
'claude-3-5-sonnet-20241022',
|
|
38
|
+
'claude-3-opus-20240229',
|
|
39
|
+
'anthropic.claude-v2:1',
|
|
40
|
+
'anthropic.claude-instant-v1',
|
|
41
|
+
]) {
|
|
42
|
+
expect(bedrockModelSupportsPromptCaching(id)).toBe(false);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('the deny-list matches families, not single dated spellings', () => {
|
|
47
|
+
// Sol's #69 review: bare aliases, -latest, and profile forms of the
|
|
48
|
+
// pre-GA families must not fall through to caching-on.
|
|
49
|
+
for (const id of [
|
|
50
|
+
'claude-3-opus',
|
|
51
|
+
'claude-3-opus-latest',
|
|
52
|
+
'claude-3-sonnet',
|
|
53
|
+
'claude-3-haiku-latest',
|
|
54
|
+
'claude-3-5-sonnet',
|
|
55
|
+
'claude-3-5-sonnet-latest',
|
|
56
|
+
'us.anthropic.claude-3-opus-latest-v1:0',
|
|
57
|
+
'apac.anthropic.claude-3-5-sonnet-v2:0',
|
|
58
|
+
'CLAUDE-3-OPUS',
|
|
59
|
+
]) {
|
|
60
|
+
expect(bedrockModelSupportsPromptCaching(id)).toBe(false);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('models with GA Bedrock caching support stay on', () => {
|
|
65
|
+
for (const id of [
|
|
66
|
+
'anthropic.claude-3-5-haiku-20241022-v1:0',
|
|
67
|
+
'claude-3-5-haiku-latest',
|
|
68
|
+
'us.anthropic.claude-3-7-sonnet-20250219-v1:0',
|
|
69
|
+
'claude-3-7-sonnet',
|
|
70
|
+
'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
|
71
|
+
'claude-sonnet-4-20250514',
|
|
72
|
+
'claude-opus-4-6',
|
|
73
|
+
'bedrock:us.anthropic.claude-opus-4-20250514-v1:0',
|
|
74
|
+
]) {
|
|
75
|
+
expect(bedrockModelSupportsPromptCaching(id)).toBe(true);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('non-Claude Bedrock ids are conservatively off, not accidentally on', () => {
|
|
80
|
+
for (const id of ['amazon.nova-pro-v1:0', 'meta.llama3-70b-instruct-v1:0', 'mistral.mistral-large-2402-v1:0']) {
|
|
81
|
+
expect(bedrockModelSupportsPromptCaching(id)).toBe(false);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('bedrock agent config', () => {
|
|
87
|
+
test('GA-supported bedrock model gets caching on, without cacheTtl', () => {
|
|
88
|
+
const parsed = validateRecipe(recipe({ provider: 'bedrock' }));
|
|
89
|
+
const config = buildFrameworkAgentConfig(
|
|
90
|
+
parsed, 'agent', 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', undefined,
|
|
91
|
+
);
|
|
92
|
+
expect(config.promptCaching).toBe(true);
|
|
93
|
+
// Bedrock rejects cache_control.ttl; the recipe default ('1h') must not
|
|
94
|
+
// ride along even though membrane also strips it.
|
|
95
|
+
expect(Object.prototype.hasOwnProperty.call(config, 'cacheTtl')).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('legacy bedrock model keeps caching suppressed', () => {
|
|
99
|
+
const parsed = validateRecipe(recipe({ provider: 'bedrock' }));
|
|
100
|
+
const config = buildFrameworkAgentConfig(
|
|
101
|
+
parsed, 'agent', 'us.anthropic.claude-3-5-sonnet-20240620-v1:0', undefined,
|
|
102
|
+
);
|
|
103
|
+
expect(config.promptCaching).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('recipe promptCaching overrides the model gate in both directions', () => {
|
|
107
|
+
const forcedOff = buildFrameworkAgentConfig(
|
|
108
|
+
validateRecipe(recipe({ provider: 'bedrock', promptCaching: false })),
|
|
109
|
+
'agent', 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', undefined,
|
|
110
|
+
);
|
|
111
|
+
expect(forcedOff.promptCaching).toBe(false);
|
|
112
|
+
|
|
113
|
+
// Explicit opt-in for an account/region whose entitlements differ
|
|
114
|
+
// from the GA table (the gate is a default, not a hard ceiling).
|
|
115
|
+
const forcedOn = buildFrameworkAgentConfig(
|
|
116
|
+
validateRecipe(recipe({ provider: 'bedrock', promptCaching: true })),
|
|
117
|
+
'agent', 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', undefined,
|
|
118
|
+
);
|
|
119
|
+
expect(forcedOn.promptCaching).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('explicit override lands at BOTH layers on any provider (composition)', () => {
|
|
123
|
+
// Anthropic recipe, explicit false: agent config off AND membrane
|
|
124
|
+
// default off — internal callers (compression/merge) read the latter.
|
|
125
|
+
const anthOff = validateRecipe(recipe({ promptCaching: false }));
|
|
126
|
+
expect(buildFrameworkAgentConfig(anthOff, 'agent', 'claude-opus-4-6', undefined).promptCaching).toBe(false);
|
|
127
|
+
expect(membraneCachingOverride(anthOff, 'claude-opus-4-6')).toEqual({ defaultPromptCaching: false });
|
|
128
|
+
|
|
129
|
+
// Bedrock explicit true and false: both layers agree with the override.
|
|
130
|
+
const bedOn = validateRecipe(recipe({ provider: 'bedrock', promptCaching: true }));
|
|
131
|
+
expect(buildFrameworkAgentConfig(bedOn, 'agent', 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', undefined).promptCaching).toBe(true);
|
|
132
|
+
expect(membraneCachingOverride(bedOn, 'us.anthropic.claude-3-5-sonnet-20241022-v2:0')).toEqual({ defaultPromptCaching: true });
|
|
133
|
+
|
|
134
|
+
const bedOff = validateRecipe(recipe({ provider: 'bedrock', promptCaching: false }));
|
|
135
|
+
expect(buildFrameworkAgentConfig(bedOff, 'agent', 'us.anthropic.claude-opus-4-1-20250805-v1:0', undefined).promptCaching).toBe(false);
|
|
136
|
+
expect(membraneCachingOverride(bedOff, 'us.anthropic.claude-opus-4-1-20250805-v1:0')).toEqual({ defaultPromptCaching: false });
|
|
137
|
+
|
|
138
|
+
// Bedrock with no explicit override: the model gate supplies the
|
|
139
|
+
// answer at both layers too.
|
|
140
|
+
const bedGate = validateRecipe(recipe({ provider: 'bedrock' }));
|
|
141
|
+
expect(membraneCachingOverride(bedGate, 'us.anthropic.claude-3-7-sonnet-20250219-v1:0')).toEqual({ defaultPromptCaching: true });
|
|
142
|
+
expect(membraneCachingOverride(bedGate, 'anthropic.claude-3-opus-20240229-v1:0')).toEqual({ defaultPromptCaching: false });
|
|
143
|
+
|
|
144
|
+
// Anthropic with no override: BOTH layers stay silent — membrane's
|
|
145
|
+
// own default (on) governs, and we don't bake it in here.
|
|
146
|
+
const anthDefault = validateRecipe(recipe());
|
|
147
|
+
expect(Object.prototype.hasOwnProperty.call(
|
|
148
|
+
buildFrameworkAgentConfig(anthDefault, 'agent', 'claude-opus-4-6', undefined), 'promptCaching',
|
|
149
|
+
)).toBe(false);
|
|
150
|
+
expect(membraneCachingOverride(anthDefault, 'claude-opus-4-6')).toEqual({});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('non-bedrock providers keep prior behavior: caching omitted, cacheTtl forwarded', () => {
|
|
154
|
+
const parsed = validateRecipe(recipe());
|
|
155
|
+
const config = buildFrameworkAgentConfig(parsed, 'agent', 'claude-opus-4-6', undefined);
|
|
156
|
+
expect(Object.prototype.hasOwnProperty.call(config, 'promptCaching')).toBe(false);
|
|
157
|
+
expect(config.cacheTtl).toBe('1h');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('recipe promptCaching applies on non-bedrock providers too', () => {
|
|
161
|
+
const parsed = validateRecipe(recipe({ promptCaching: false }));
|
|
162
|
+
const config = buildFrameworkAgentConfig(parsed, 'agent', 'claude-opus-4-6', undefined);
|
|
163
|
+
expect(config.promptCaching).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('non-boolean promptCaching is rejected at validation', () => {
|
|
167
|
+
expect(() => validateRecipe(recipe({ promptCaching: 'yes' }))).toThrow(/promptCaching/);
|
|
168
|
+
expect(() => validateRecipe(recipe({ promptCaching: 1 }))).toThrow(/promptCaching/);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard-recipe memory defaults (buildFrameworkStrategy).
|
|
3
|
+
*
|
|
4
|
+
* A recipe that omits strategy tuning must get the fleet-standard shape:
|
|
5
|
+
* autobiographical + adaptiveResolution + kv-stable folding + same-model
|
|
6
|
+
* compression + summaries voiced as the agent itself. Explicit recipe values
|
|
7
|
+
* always win. DEFAULT_RECIPE must not enable the opt-in modules
|
|
8
|
+
* (subagents/lessons/retrieval).
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import { buildFrameworkStrategy } from '../src/framework-strategy.js';
|
|
12
|
+
import { DEFAULT_RECIPE, validateRecipe } from '../src/recipe.js';
|
|
13
|
+
|
|
14
|
+
function recipe(agent: Record<string, unknown> = {}) {
|
|
15
|
+
return validateRecipe({
|
|
16
|
+
name: 'framework-strategy-defaults',
|
|
17
|
+
agent: {
|
|
18
|
+
systemPrompt: 'sys',
|
|
19
|
+
...agent,
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function configView(strategy: object): Record<string, unknown> {
|
|
25
|
+
return (strategy as { config?: Record<string, unknown> }).config ?? {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('standard-recipe memory defaults', () => {
|
|
29
|
+
test('omitted strategy gets kv-stable folding, same-model compression, and agent-voiced summaries', () => {
|
|
30
|
+
const strategy = buildFrameworkStrategy(
|
|
31
|
+
recipe({ name: 'Mira' }),
|
|
32
|
+
'some-model',
|
|
33
|
+
'America/Los_Angeles',
|
|
34
|
+
);
|
|
35
|
+
const config = configView(strategy);
|
|
36
|
+
expect(config.adaptiveResolution).toBe(true);
|
|
37
|
+
expect(config.foldingStrategy).toBe('kv-stable');
|
|
38
|
+
expect(config.compressionModel).toBe('some-model');
|
|
39
|
+
expect(config.summaryParticipant).toBe('Mira');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('explicit recipe values override the defaults', () => {
|
|
43
|
+
const strategy = buildFrameworkStrategy(
|
|
44
|
+
recipe({
|
|
45
|
+
name: 'Mira',
|
|
46
|
+
strategy: {
|
|
47
|
+
type: 'autobiographical',
|
|
48
|
+
foldingStrategy: 'flat-profile',
|
|
49
|
+
compressionModel: 'pinned-model',
|
|
50
|
+
summaryParticipant: 'Someone Else',
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
'some-model',
|
|
54
|
+
'America/Los_Angeles',
|
|
55
|
+
);
|
|
56
|
+
const config = configView(strategy);
|
|
57
|
+
expect(config.foldingStrategy).toBe('flat-profile');
|
|
58
|
+
expect(config.compressionModel).toBe('pinned-model');
|
|
59
|
+
expect(config.summaryParticipant).toBe('Someone Else');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('adaptiveResolution opt-out leaves foldingStrategy unset', () => {
|
|
63
|
+
const strategy = buildFrameworkStrategy(
|
|
64
|
+
recipe({ strategy: { type: 'autobiographical', adaptiveResolution: false } }),
|
|
65
|
+
'some-model',
|
|
66
|
+
'America/Los_Angeles',
|
|
67
|
+
);
|
|
68
|
+
const config = configView(strategy);
|
|
69
|
+
expect(config.adaptiveResolution).toBe(false);
|
|
70
|
+
expect(config.foldingStrategy).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("without an agent name the summary voice falls back to the library's 'Claude' default", () => {
|
|
74
|
+
const strategy = buildFrameworkStrategy(
|
|
75
|
+
recipe(),
|
|
76
|
+
'some-model',
|
|
77
|
+
'America/Los_Angeles',
|
|
78
|
+
);
|
|
79
|
+
expect(configView(strategy).summaryParticipant).toBe('Claude');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('DEFAULT_RECIPE does not enable the opt-in modules', () => {
|
|
83
|
+
const modules = DEFAULT_RECIPE.modules ?? {};
|
|
84
|
+
expect(modules).not.toHaveProperty('subagents');
|
|
85
|
+
expect(modules).not.toHaveProperty('lessons');
|
|
86
|
+
expect(modules).not.toHaveProperty('retrieval');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Identity module (archipelago-home client) + the tools↔utilities surface
|
|
2
|
+
// flags on mcpl-admin and observers. See docs/home-node.md §4 and the af
|
|
3
|
+
// utils meta-tool (Module.getUtilities).
|
|
4
|
+
//
|
|
5
|
+
// Design under test: the AGENT surface is credential-free (status /
|
|
6
|
+
// accept_invite, no tokens in any result); credentials exist only on the
|
|
7
|
+
// HOST-facing API (accessFor/httpAuthFor) that the MCPL dial provider
|
|
8
|
+
// and HTTP helpers consume outside model context.
|
|
9
|
+
import { describe, it, expect } from 'bun:test';
|
|
10
|
+
import { mkdtempSync, existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
import { IdentityModule } from '../src/modules/identity-module.ts';
|
|
16
|
+
import { McplAdminModule } from '../src/modules/mcpl-admin-module.ts';
|
|
17
|
+
import { ObserversModule } from '../src/modules/observers-module.ts';
|
|
18
|
+
|
|
19
|
+
const call = (name: string, input: unknown) => ({ id: 't1', name, input });
|
|
20
|
+
|
|
21
|
+
function fakeHome(routes: Record<string, (body: any) => { status: number; json: unknown }>): typeof fetch {
|
|
22
|
+
return (async (url: string | URL | Request, init?: RequestInit) => {
|
|
23
|
+
const path = new URL(String(url)).pathname;
|
|
24
|
+
const handler = routes[path];
|
|
25
|
+
if (!handler) return new Response('{}', { status: 404 });
|
|
26
|
+
const body = JSON.parse(String(init?.body ?? '{}'));
|
|
27
|
+
const { status, json } = handler(body);
|
|
28
|
+
return new Response(JSON.stringify(json), { status });
|
|
29
|
+
}) as typeof fetch;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('identity module', () => {
|
|
33
|
+
it('is utilities-only, and the agent surface never mentions or returns credentials', async () => {
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), 'ident-'));
|
|
35
|
+
const mod = new IdentityModule({ keyPath: join(dir, 'identity-key.pem'), home: 'id.test' });
|
|
36
|
+
expect(mod.getTools()).toEqual([]);
|
|
37
|
+
expect(mod.getUtilities().map((u) => u.name)).toEqual(['status', 'accept_invite']);
|
|
38
|
+
// Framing check: no crypto/credential vocabulary in agent-visible text.
|
|
39
|
+
const visible = JSON.stringify(mod.getUtilities()).toLowerCase();
|
|
40
|
+
for (const scary of ['token', 'key', 'sign', 'proof', 'ed25519', 'mint', 'bearer']) {
|
|
41
|
+
expect(visible).not.toContain(scary);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const res = await mod.handleToolCall(call('status', {}));
|
|
45
|
+
expect(res.success).toBe(true);
|
|
46
|
+
const data = res.data as { registeredAs: unknown; note: string };
|
|
47
|
+
expect(data.registeredAs).toBe(null);
|
|
48
|
+
expect(data.note).toContain('invitation code');
|
|
49
|
+
expect(JSON.stringify(res.data)).not.toContain('ed25519'); // key exists on disk, not in results
|
|
50
|
+
expect(existsSync(join(dir, 'identity-key.pem'))).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('accept_invite registers, echoes NO credential, and is one-time', async () => {
|
|
54
|
+
const dir = mkdtempSync(join(tmpdir(), 'ident-'));
|
|
55
|
+
let seen: any = null;
|
|
56
|
+
const mod = new IdentityModule({
|
|
57
|
+
keyPath: join(dir, 'k.pem'),
|
|
58
|
+
home: 'id.test',
|
|
59
|
+
fetchImpl: fakeHome({
|
|
60
|
+
'/enroll': (body) => {
|
|
61
|
+
seen = body;
|
|
62
|
+
return { status: 200, json: { sub: 'agent:ferro@guest', token: 'aid1.SECRET.x' } };
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
const res = await mod.handleToolCall(call('accept_invite', { invite: 'inv_1', name: 'Ferro' }));
|
|
67
|
+
expect(res.success).toBe(true);
|
|
68
|
+
expect((res.data as any).id).toBe('agent:ferro@guest');
|
|
69
|
+
// The home node's response token must NOT reach the agent.
|
|
70
|
+
expect(JSON.stringify(res.data)).not.toContain('aid1.');
|
|
71
|
+
|
|
72
|
+
// Wire-level: the signed statement is the spec's, verifiable by the module's own key.
|
|
73
|
+
const raw = Buffer.from(seen.id.slice('ed25519:'.length), 'base64url');
|
|
74
|
+
const key = createPublicKey({
|
|
75
|
+
key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), raw]),
|
|
76
|
+
format: 'der', type: 'spki',
|
|
77
|
+
});
|
|
78
|
+
const statement = `archipelago-enroll|v1|id.test|inv_1|${seen.timestamp}`;
|
|
79
|
+
expect(cryptoVerify(null, Buffer.from(statement), key, Buffer.from(seen.proof, 'base64url'))).toBe(true);
|
|
80
|
+
|
|
81
|
+
const rec = JSON.parse(readFileSync(join(dir, 'k.json'), 'utf8'));
|
|
82
|
+
expect(rec.sub).toBe('agent:ferro@guest');
|
|
83
|
+
|
|
84
|
+
const again = await mod.handleToolCall(call('accept_invite', { invite: 'inv_2', name: 'Ferro2' }));
|
|
85
|
+
expect(again.success).toBe(false);
|
|
86
|
+
expect(again.error).toContain('Already registered');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('host-facing accessFor: requires registration, then exchanges per call', async () => {
|
|
90
|
+
const dir = mkdtempSync(join(tmpdir(), 'ident-'));
|
|
91
|
+
let mints = 0;
|
|
92
|
+
const mod = new IdentityModule({
|
|
93
|
+
keyPath: join(dir, 'k.pem'),
|
|
94
|
+
home: 'id.test',
|
|
95
|
+
defaultAudience: 'eidoverse',
|
|
96
|
+
fetchImpl: fakeHome({
|
|
97
|
+
'/enroll': () => ({ status: 200, json: { sub: 'agent:a@guest', token: 't0' } }),
|
|
98
|
+
'/token': (body) => body.audience === 'eidoverse'
|
|
99
|
+
? { status: 200, json: { token: `aid1.fresh.${++mints}` } }
|
|
100
|
+
: { status: 400, json: { error: 'unknown audience' } },
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
await expect(mod.accessFor()).rejects.toThrow(/not registered/);
|
|
104
|
+
|
|
105
|
+
await mod.handleToolCall(call('accept_invite', { invite: 'i', name: 'A' }));
|
|
106
|
+
expect(await mod.accessFor()).toBe('aid1.fresh.1');
|
|
107
|
+
expect(await mod.accessFor('eidoverse')).toBe('aid1.fresh.2'); // fresh per call — dial-time rotation
|
|
108
|
+
expect((await mod.httpAuthFor()).authorization).toBe('Bearer aid1.fresh.3');
|
|
109
|
+
await expect(mod.accessFor('nope')).rejects.toThrow(/unknown audience/);
|
|
110
|
+
expect(mod.isEnrolled()).toBe(true);
|
|
111
|
+
expect(mod.sub()).toBe('agent:a@guest');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('mcpl-admin access grants', () => {
|
|
116
|
+
it('deploy with `access` requires identity wiring, and stores the NAME not a credential', async () => {
|
|
117
|
+
const dir = mkdtempSync(join(tmpdir(), 'mcpl-'));
|
|
118
|
+
const mod = new McplAdminModule({ overlayPath: join(dir, 'overlay.json') });
|
|
119
|
+
// stub framework so the deploy reaches the access check
|
|
120
|
+
mod.setFramework({
|
|
121
|
+
listMcplServers: () => [],
|
|
122
|
+
connectMcplServer: async () => {},
|
|
123
|
+
restartMcplServer: async () => {},
|
|
124
|
+
disconnectMcplServer: async () => {},
|
|
125
|
+
} as any);
|
|
126
|
+
|
|
127
|
+
// no identity wired → clear bounce
|
|
128
|
+
const refused = await mod.handleToolCall(call('mcpl_deploy', {
|
|
129
|
+
id: 'worlds', url: 'wss://example.test/mcpl', access: 'eidoverse',
|
|
130
|
+
}) as any);
|
|
131
|
+
expect(refused.success).toBe(false);
|
|
132
|
+
expect(refused.error).toContain('identity');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('surface flags: default keeps four first-class tools; utilities parks them', () => {
|
|
136
|
+
const asTools = new McplAdminModule({});
|
|
137
|
+
expect(asTools.getTools().length).toBe(4);
|
|
138
|
+
expect(asTools.getUtilities().length).toBe(0);
|
|
139
|
+
|
|
140
|
+
const asUtils = new McplAdminModule({ surface: 'utilities' });
|
|
141
|
+
expect(asUtils.getTools().length).toBe(0);
|
|
142
|
+
expect(asUtils.getUtilities().map((u) => u.name).sort()).toEqual(
|
|
143
|
+
['mcpl_deploy', 'mcpl_list', 'mcpl_restart', 'mcpl_unload'],
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('observers surface flag', () => {
|
|
149
|
+
it('same definitions on either surface', () => {
|
|
150
|
+
const dir = mkdtempSync(join(tmpdir(), 'obs-'));
|
|
151
|
+
const asTools = new ObserversModule({ path: join(dir, 'observers.json') });
|
|
152
|
+
const asUtils = new ObserversModule({ path: join(dir, 'observers.json'), surface: 'utilities' });
|
|
153
|
+
expect(asTools.getTools().map((t) => t.name)).toEqual(asUtils.getUtilities().map((u) => u.name));
|
|
154
|
+
expect(asTools.getUtilities().length).toBe(0);
|
|
155
|
+
expect(asUtils.getTools().length).toBe(0);
|
|
156
|
+
});
|
|
157
|
+
});
|