@animalabs/connectome-host 0.3.5 → 0.3.8
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 +6 -0
- package/.github/workflows/ci.yml +52 -0
- package/.github/workflows/publish.yml +2 -2
- package/README.md +5 -0
- package/bun.lock +14 -6
- package/docs/AGENT-ONBOARDING.md +6 -1
- package/package.json +6 -4
- package/scripts/import-codex-rollout.ts +288 -0
- package/src/call-ledger.ts +371 -0
- package/src/call-pricing.ts +119 -0
- package/src/framework-agent-config.ts +51 -0
- package/src/framework-strategy.ts +70 -0
- package/src/index.ts +130 -105
- package/src/logging-adapter.ts +105 -12
- package/src/mcpl-config.ts +1 -0
- package/src/modules/channel-mode-module.ts +14 -16
- package/src/modules/fleet-module.ts +17 -7
- package/src/modules/mcpl-admin-module.ts +6 -15
- package/src/modules/settings-module.ts +83 -59
- package/src/modules/subscription-gc-module.ts +17 -20
- package/src/modules/time-module.ts +15 -9
- package/src/modules/web-ui-module.ts +88 -10
- package/src/modules/web-ui-observers.ts +35 -9
- package/src/recipe.ts +133 -6
- package/src/state/agent-tree-reducer.ts +17 -3
- package/src/strategies/frontdesk-strategy.ts +15 -9
- package/src/web/protocol.ts +89 -0
- package/test/agent-tree-reducer.test.ts +35 -3
- package/test/autobio-progress-snapshot.test.ts +27 -12
- package/test/call-ledger.test.ts +91 -0
- package/test/call-pricing.test.ts +69 -0
- package/test/framework-fkm-composition.test.ts +160 -0
- package/test/frontdesk-strategy.test.ts +10 -0
- package/test/import-codex-rollout.test.ts +36 -0
- package/test/logging-adapter-refusal.test.ts +57 -0
- package/test/logging-adapter.test.ts +58 -1
- package/test/recipe-cache-ttl.test.ts +7 -3
- package/test/recipe-compression-fallback.test.ts +36 -0
- package/test/recipe-primary-summary-fallback.test.ts +40 -0
- package/test/recipe-provider.test.ts +36 -0
- package/test/recipe-think-policy.test.ts +39 -0
- package/test/recipe-timezone.test.ts +20 -0
- package/test/subscription-gc-module.test.ts +7 -5
- package/test/time-module.test.ts +13 -0
- package/test/web-ui-observers.test.ts +70 -2
- package/web/package-lock.json +425 -302
- package/web/src/App.tsx +9 -0
- package/web/src/ObserverGate.tsx +21 -11
- package/web/src/Usage.tsx +116 -2
- package/web/src/wire.ts +5 -2
- package/web/bun.lock +0 -357
|
@@ -86,9 +86,12 @@ describe('AgentTreeReducer', () => {
|
|
|
86
86
|
});
|
|
87
87
|
|
|
88
88
|
test('inference:exhausted from in-band stream abort also produces cancelled (P1 #3)', () => {
|
|
89
|
-
// Postmortem 2026-05-28 F1:
|
|
90
|
-
//
|
|
91
|
-
//
|
|
89
|
+
// Postmortem 2026-05-28 F1: AF used to emit inference:exhausted for
|
|
90
|
+
// budget-restart / stream-side cancel paths. Same semantics as aborted —
|
|
91
|
+
// benign termination, not a fault. AF PR #55 (≥ 0.6.5) suppresses the
|
|
92
|
+
// endTurn/budget-restart cases at the source, but genuine cancels and
|
|
93
|
+
// reboot-induced exhausted still arrive, and the mapping must keep
|
|
94
|
+
// handling streams from older AF.
|
|
92
95
|
const r = new AgentTreeReducer();
|
|
93
96
|
r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
|
|
94
97
|
r.applyEvent({ type: 'inference:exhausted', agentName: 'a', error: 'budget', timestamp: ts(1) });
|
|
@@ -97,6 +100,35 @@ describe('AgentTreeReducer', () => {
|
|
|
97
100
|
expect(node.status).toBe('cancelled');
|
|
98
101
|
});
|
|
99
102
|
|
|
103
|
+
test('inference:turn_ended is terminal in its own right (AF PR #55: no trailing exhausted)', () => {
|
|
104
|
+
// endTurn = the agent deliberately ended its turn — a successful
|
|
105
|
+
// terminal. Before AF PR #55 the terminal state of endTurn'd agents came
|
|
106
|
+
// from the SPURIOUS inference:exhausted the framework emitted right
|
|
107
|
+
// after; ≥ 0.6.5 no longer sends it, so without this transition an
|
|
108
|
+
// endTurn'd agent would sit in the tree as 'running' forever.
|
|
109
|
+
const r = new AgentTreeReducer();
|
|
110
|
+
r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
|
|
111
|
+
r.applyEvent({ type: 'inference:turn_ended', agentName: 'a', timestamp: ts(1) });
|
|
112
|
+
const node = r.getNode('a')!;
|
|
113
|
+
expect(node.phase).toBe('done');
|
|
114
|
+
expect(node.status).toBe('completed');
|
|
115
|
+
expect(node.completedAt).toBe(ts(1));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('older AF: the exhausted trailing an endTurn re-terminates as cancelled (harmless)', () => {
|
|
119
|
+
// Pre-0.6.5 streams still deliver turn_ended followed by the spurious
|
|
120
|
+
// exhausted. The node flips completed → cancelled one event later; both
|
|
121
|
+
// are terminal-and-benign, so renderers stay correct either way. Pinned
|
|
122
|
+
// so the compat behavior is a documented choice, not an accident.
|
|
123
|
+
const r = new AgentTreeReducer();
|
|
124
|
+
r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
|
|
125
|
+
r.applyEvent({ type: 'inference:turn_ended', agentName: 'a', timestamp: ts(1) });
|
|
126
|
+
r.applyEvent({ type: 'inference:exhausted', agentName: 'a', error: 'Stream aborted: user', timestamp: ts(2) });
|
|
127
|
+
const node = r.getNode('a')!;
|
|
128
|
+
expect(node.phase).toBe('cancelled');
|
|
129
|
+
expect(node.status).toBe('cancelled');
|
|
130
|
+
});
|
|
131
|
+
|
|
100
132
|
// Postmortem 2026-05-28 F1: the reducer never transitions status to 'completed'.
|
|
101
133
|
// inference:completed only sets phase='done', leaving status='running' for the
|
|
102
134
|
// entire lifetime of the agent — which is fine until a later aborted/exhausted/
|
|
@@ -1,14 +1,32 @@
|
|
|
1
1
|
import { describe, test, expect } from 'bun:test';
|
|
2
2
|
import { AutobiographicalStrategy } from '@animalabs/agent-framework';
|
|
3
|
+
import { ContextManager } from '@animalabs/context-manager';
|
|
4
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
async function initializedStrategy() {
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), 'autobio-progress-'));
|
|
10
|
+
const path = join(root, 'store');
|
|
11
|
+
const strategy = new AutobiographicalStrategy({
|
|
12
|
+
compressionModel: 'claude-sonnet-4-5-20250929',
|
|
13
|
+
autoTickOnNewMessage: false,
|
|
14
|
+
});
|
|
15
|
+
const cm = await ContextManager.open({ path, strategy });
|
|
16
|
+
return {
|
|
17
|
+
strategy,
|
|
18
|
+
close: () => {
|
|
19
|
+
cm.close();
|
|
20
|
+
rmSync(root, { recursive: true, force: true });
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
3
24
|
|
|
4
25
|
// Guards against silent breakage if upstream renames the protected fields
|
|
5
26
|
// that getProgressSnapshot reads. The shape is what warmup-session.ts relies on.
|
|
6
27
|
describe('AutobiographicalStrategy.getProgressSnapshot', () => {
|
|
7
|
-
test('returns the expected shape
|
|
8
|
-
const strategy =
|
|
9
|
-
compressionModel: 'claude-sonnet-4-5-20250929',
|
|
10
|
-
autoTickOnNewMessage: false,
|
|
11
|
-
});
|
|
28
|
+
test('returns the expected shape after branch state is initialized', async () => {
|
|
29
|
+
const { strategy, close } = await initializedStrategy();
|
|
12
30
|
const snapshot = strategy.getProgressSnapshot();
|
|
13
31
|
expect(snapshot).toEqual({
|
|
14
32
|
totalChunks: 0,
|
|
@@ -18,16 +36,12 @@ describe('AutobiographicalStrategy.getProgressSnapshot', () => {
|
|
|
18
36
|
summaryCounts: { l1: 0, l2: 0, l3: 0 },
|
|
19
37
|
pending: false,
|
|
20
38
|
});
|
|
39
|
+
close();
|
|
21
40
|
});
|
|
22
41
|
|
|
23
|
-
test('exposes the keys warmup-session.ts depends on', () => {
|
|
24
|
-
const strategy =
|
|
25
|
-
compressionModel: 'claude-sonnet-4-5-20250929',
|
|
26
|
-
autoTickOnNewMessage: false,
|
|
27
|
-
});
|
|
42
|
+
test('exposes the keys warmup-session.ts depends on', async () => {
|
|
43
|
+
const { strategy, close } = await initializedStrategy();
|
|
28
44
|
const s = strategy.getProgressSnapshot();
|
|
29
|
-
// Property access — if any rename happens upstream these become undefined
|
|
30
|
-
// and the test fails loudly.
|
|
31
45
|
expect(typeof s.totalChunks).toBe('number');
|
|
32
46
|
expect(typeof s.chunksCompressed).toBe('number');
|
|
33
47
|
expect(typeof s.l1QueueLength).toBe('number');
|
|
@@ -36,5 +50,6 @@ describe('AutobiographicalStrategy.getProgressSnapshot', () => {
|
|
|
36
50
|
expect(typeof s.summaryCounts.l1).toBe('number');
|
|
37
51
|
expect(typeof s.summaryCounts.l2).toBe('number');
|
|
38
52
|
expect(typeof s.summaryCounts.l3).toBe('number');
|
|
53
|
+
close();
|
|
39
54
|
});
|
|
40
55
|
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { CallLedger, summarizeCacheControls, type ProviderCallRecord } from '../src/call-ledger.js';
|
|
3
|
+
|
|
4
|
+
const at = (seconds: number): string => new Date(Date.UTC(2026, 0, 1, 0, 0, seconds)).toISOString();
|
|
5
|
+
|
|
6
|
+
function call(overrides: Partial<ProviderCallRecord> = {}): ProviderCallRecord {
|
|
7
|
+
return {
|
|
8
|
+
timestamp: at(0),
|
|
9
|
+
kind: 'stream',
|
|
10
|
+
durationMs: 1000,
|
|
11
|
+
model: 'claude-fable-5',
|
|
12
|
+
messages: 10,
|
|
13
|
+
inputTokens: 2,
|
|
14
|
+
outputTokens: 100,
|
|
15
|
+
cacheReadTokens: 0,
|
|
16
|
+
cacheWriteTokens: 0,
|
|
17
|
+
cacheBreakpoints: 4,
|
|
18
|
+
cacheTtls: ['1h', '1h', '1h', '1h'],
|
|
19
|
+
stopReason: 'end_turn',
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('CallLedger cache verdicts', () => {
|
|
25
|
+
test('distinguishes first write, hit, and expired rewrite using the effective TTL', () => {
|
|
26
|
+
const ledger = new CallLedger({ hydrate: false, defaultTtl: '1h' });
|
|
27
|
+
ledger.record(call({ timestamp: at(0), cacheWriteTokens: 100_000 }));
|
|
28
|
+
ledger.record(call({ timestamp: at(1800), cacheReadTokens: 100_000 }));
|
|
29
|
+
ledger.record(call({ timestamp: at(5500), cacheWriteTokens: 101_000 }));
|
|
30
|
+
|
|
31
|
+
const rows = ledger.snapshot().rows;
|
|
32
|
+
expect(rows.map((r) => r.verdict)).toEqual(['first-write', 'HIT', 'rewrite:expired']);
|
|
33
|
+
expect(rows[2]!.cause).toContain('gap 3700s > ttl 3600s');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('calls without cache flags are visibly uncached', () => {
|
|
37
|
+
const ledger = new CallLedger({ hydrate: false });
|
|
38
|
+
ledger.record(call({ kind: 'complete', cacheBreakpoints: 0, cacheTtls: [], inputTokens: 48_000 }));
|
|
39
|
+
expect(ledger.snapshot().rows[0]).toMatchObject({
|
|
40
|
+
verdict: 'uncached',
|
|
41
|
+
cause: 'no cache_control flags in request',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('reports hit+extend and an aggregate cache ratio', () => {
|
|
46
|
+
const ledger = new CallLedger({ hydrate: false });
|
|
47
|
+
ledger.record(call({ inputTokens: 100, cacheReadTokens: 900, cacheWriteTokens: 100 }));
|
|
48
|
+
const snap = ledger.snapshot();
|
|
49
|
+
expect(snap.rows[0]!.verdict).toBe('hit+extend');
|
|
50
|
+
expect(snap.summary.cacheHitRatio).toBeCloseTo(900 / 1100);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('allocates an auditable per-call cost and reconciles the retained total', () => {
|
|
54
|
+
const ledger = new CallLedger({ hydrate: false });
|
|
55
|
+
ledger.record(call({
|
|
56
|
+
cacheWriteTokens: 336_010,
|
|
57
|
+
cacheWrite5mTokens: 336_010,
|
|
58
|
+
cacheWrite1hTokens: 0,
|
|
59
|
+
cacheWriteBucketsAuthoritative: true,
|
|
60
|
+
cacheTtls: ['5m'],
|
|
61
|
+
outputTokens: 639,
|
|
62
|
+
serviceTier: 'standard',
|
|
63
|
+
inferenceGeo: 'global',
|
|
64
|
+
}));
|
|
65
|
+
ledger.record(call({ model: 'unknown-model' }));
|
|
66
|
+
|
|
67
|
+
const snap = ledger.snapshot();
|
|
68
|
+
expect(snap.rows[0]!.cost?.total).toBeCloseTo(4.232095, 9);
|
|
69
|
+
expect(snap.rows[1]!.cost).toBeUndefined();
|
|
70
|
+
expect(snap.summary.cost).toMatchObject({
|
|
71
|
+
total: 4.232095,
|
|
72
|
+
pricedCalls: 1,
|
|
73
|
+
unpricedCalls: 1,
|
|
74
|
+
currency: 'USD',
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('does not guess a cache-write TTL bucket from request flags', () => {
|
|
79
|
+
const ledger = new CallLedger({ hydrate: false });
|
|
80
|
+
ledger.record(call({ cacheWriteTokens: 100_000, cacheTtls: ['1h'] }));
|
|
81
|
+
expect(ledger.snapshot().rows[0]!.cost).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('summarizeCacheControls inventories markers without retaining content', () => {
|
|
86
|
+
const raw = {
|
|
87
|
+
system: [{ type: 'text', text: 'secret', cache_control: { type: 'ephemeral', ttl: '1h' } }],
|
|
88
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: 'private', cache_control: { type: 'ephemeral' } }] }],
|
|
89
|
+
};
|
|
90
|
+
expect(summarizeCacheControls(raw)).toEqual({ count: 2, ttls: ['1h', 'default'] });
|
|
91
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { ANTHROPIC_PRICING_VERSION, priceAnthropicCall } from '../src/call-pricing.js';
|
|
3
|
+
|
|
4
|
+
const timestamp = '2026-07-13T12:00:00.000Z';
|
|
5
|
+
|
|
6
|
+
function usage(overrides: Partial<Parameters<typeof priceAnthropicCall>[2]> = {}) {
|
|
7
|
+
return {
|
|
8
|
+
inputTokens: 0,
|
|
9
|
+
outputTokens: 0,
|
|
10
|
+
cacheReadTokens: 0,
|
|
11
|
+
cacheWrite5mTokens: 0,
|
|
12
|
+
cacheWrite1hTokens: 0,
|
|
13
|
+
unclassifiedCacheWriteTokens: 0,
|
|
14
|
+
serviceTier: 'standard',
|
|
15
|
+
inferenceGeo: 'global',
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('Anthropic per-call pricing', () => {
|
|
21
|
+
test('reproduces the ledger-dashboard Fable/Mythos 5m sample exactly', () => {
|
|
22
|
+
const cost = priceAnthropicCall('claude-fable-5', timestamp, usage({
|
|
23
|
+
inputTokens: 2,
|
|
24
|
+
cacheWrite5mTokens: 336_010,
|
|
25
|
+
outputTokens: 639,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
expect(cost?.total).toBeCloseTo(4.232095, 9);
|
|
29
|
+
expect(cost).toMatchObject({
|
|
30
|
+
cacheWrite5m: 4.200125,
|
|
31
|
+
cacheWrite1h: 0,
|
|
32
|
+
currency: 'USD',
|
|
33
|
+
grade: 'billing',
|
|
34
|
+
pricingVersion: ANTHROPIC_PRICING_VERSION,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('prices 1h writes at 2x base input and cache reads at 0.1x', () => {
|
|
39
|
+
const write = priceAnthropicCall('claude-mythos-5', timestamp, usage({ cacheWrite1hTokens: 336_010 }));
|
|
40
|
+
const read = priceAnthropicCall('claude-mythos-5', timestamp, usage({ cacheReadTokens: 336_010 }));
|
|
41
|
+
expect(write?.cacheWrite1h).toBeCloseTo(6.7202, 9);
|
|
42
|
+
expect(read?.cacheRead).toBeCloseTo(0.33601, 9);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('applies the US-only inference multiplier to every token category', () => {
|
|
46
|
+
const global = priceAnthropicCall('claude-fable-5', timestamp, usage({ inputTokens: 1_000_000 }));
|
|
47
|
+
const us = priceAnthropicCall('claude-fable-5', timestamp, usage({
|
|
48
|
+
inputTokens: 1_000_000,
|
|
49
|
+
inferenceGeo: 'us',
|
|
50
|
+
}));
|
|
51
|
+
expect(global?.total).toBe(10);
|
|
52
|
+
expect(us?.total).toBeCloseTo(11, 9);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('leaves unknown rates, custom tiers, and unclassified writes unpriced', () => {
|
|
56
|
+
expect(priceAnthropicCall('unknown-model', timestamp, usage())).toBeUndefined();
|
|
57
|
+
expect(priceAnthropicCall('claude-fable-5', timestamp, usage({ serviceTier: 'priority' }))).toBeUndefined();
|
|
58
|
+
expect(priceAnthropicCall('claude-fable-5', timestamp, usage({
|
|
59
|
+
unclassifiedCacheWriteTokens: 1,
|
|
60
|
+
}))).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('honors the published Sonnet 5 promotional cutoff', () => {
|
|
64
|
+
const promo = priceAnthropicCall('claude-sonnet-5', '2026-08-31T23:59:59Z', usage({ inputTokens: 1_000_000 }));
|
|
65
|
+
const standard = priceAnthropicCall('claude-sonnet-5', '2026-09-01T00:00:00Z', usage({ inputTokens: 1_000_000 }));
|
|
66
|
+
expect(promo?.total).toBe(2);
|
|
67
|
+
expect(standard?.total).toBe(3);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { buildFrameworkAgentConfig } from '../src/framework-agent-config.js';
|
|
6
|
+
import { buildFrameworkStrategy } from '../src/framework-strategy.js';
|
|
7
|
+
import { loadSavedRecipe, saveRecipe, validateRecipe } from '../src/recipe.js';
|
|
8
|
+
|
|
9
|
+
function recipe(agent: Record<string, unknown> = {}) {
|
|
10
|
+
return {
|
|
11
|
+
name: 'framework-fkm-composition',
|
|
12
|
+
agent: {
|
|
13
|
+
systemPrompt: 'sys',
|
|
14
|
+
...agent,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function strategyConfigView(strategy: object): Record<string, unknown> {
|
|
20
|
+
return (strategy as { config?: Record<string, unknown> }).config ?? {};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const tempDirs: string[] = [];
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
while (tempDirs.length > 0) {
|
|
27
|
+
rmSync(tempDirs.pop()!, { force: true, recursive: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('framework FKM composition', () => {
|
|
32
|
+
test('preserves the pre-merge AgentConfig shape field-for-field when FKM additions are omitted', () => {
|
|
33
|
+
const parsed = validateRecipe(recipe({
|
|
34
|
+
provider: 'openai-responses',
|
|
35
|
+
maxTokens: 4_096,
|
|
36
|
+
maxStreamTokens: 80_000,
|
|
37
|
+
contextBudgetTokens: 120_000,
|
|
38
|
+
cacheTtl: '5m',
|
|
39
|
+
responses: {
|
|
40
|
+
reasoningEffort: 'medium',
|
|
41
|
+
reasoningContext: 'current_turn',
|
|
42
|
+
serviceTier: 'priority',
|
|
43
|
+
compactThreshold: 90_000,
|
|
44
|
+
},
|
|
45
|
+
thinking: {
|
|
46
|
+
enabled: true,
|
|
47
|
+
budgetTokens: 1_024,
|
|
48
|
+
},
|
|
49
|
+
refusalHandling: {
|
|
50
|
+
autoRewind: true,
|
|
51
|
+
maxRewinds: 2,
|
|
52
|
+
announceHumanTurns: false,
|
|
53
|
+
},
|
|
54
|
+
}));
|
|
55
|
+
const strategy = { kind: 'baseline-strategy' } as never;
|
|
56
|
+
|
|
57
|
+
expect(buildFrameworkAgentConfig(parsed, 'agent', 'model', strategy)).toEqual({
|
|
58
|
+
name: 'agent',
|
|
59
|
+
model: 'model',
|
|
60
|
+
systemPrompt: 'sys',
|
|
61
|
+
maxTokens: 4_096,
|
|
62
|
+
maxStreamTokens: 80_000,
|
|
63
|
+
contextBudgetTokens: 120_000,
|
|
64
|
+
cacheTtl: '5m',
|
|
65
|
+
providerParams: {
|
|
66
|
+
reasoning: {
|
|
67
|
+
effort: 'medium',
|
|
68
|
+
context: 'current_turn',
|
|
69
|
+
},
|
|
70
|
+
service_tier: 'priority',
|
|
71
|
+
context_management: [{
|
|
72
|
+
type: 'compaction',
|
|
73
|
+
compact_threshold: 90_000,
|
|
74
|
+
}],
|
|
75
|
+
},
|
|
76
|
+
strategy,
|
|
77
|
+
thinking: {
|
|
78
|
+
enabled: true,
|
|
79
|
+
budgetTokens: 1_024,
|
|
80
|
+
},
|
|
81
|
+
refusalHandling: {
|
|
82
|
+
autoRewind: true,
|
|
83
|
+
maxRewinds: 2,
|
|
84
|
+
announceHumanTurns: false,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('composes validation, serialization, and AF/CM wiring for the merged FKM settings without cross-agent bleed', () => {
|
|
90
|
+
const parsed = validateRecipe(recipe({
|
|
91
|
+
provider: 'openai-responses',
|
|
92
|
+
maxTokens: 4_096,
|
|
93
|
+
responses: {
|
|
94
|
+
reasoningEffort: 'high',
|
|
95
|
+
reasoningContext: 'all_turns',
|
|
96
|
+
serviceTier: 'priority',
|
|
97
|
+
},
|
|
98
|
+
sameRoundThinkTextPolicy: 'private',
|
|
99
|
+
refusalHandling: {
|
|
100
|
+
autoRewind: true,
|
|
101
|
+
primarySummaryFallback: {
|
|
102
|
+
enabled: true,
|
|
103
|
+
maxNewSummaries: 4,
|
|
104
|
+
requestBudgetTokens: 216_000,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
strategy: {
|
|
108
|
+
type: 'autobiographical',
|
|
109
|
+
compressionRefusalCurveFallbacks: 2,
|
|
110
|
+
compressionContextBudgetTokens: 19_000,
|
|
111
|
+
},
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
const dir = mkdtempSync(join(tmpdir(), 'connectome-fkm-'));
|
|
115
|
+
tempDirs.push(dir);
|
|
116
|
+
saveRecipe(dir, parsed);
|
|
117
|
+
const reloaded = loadSavedRecipe(dir);
|
|
118
|
+
expect(reloaded).not.toBeNull();
|
|
119
|
+
|
|
120
|
+
expect(reloaded!.agent.sameRoundThinkTextPolicy).toBe('private');
|
|
121
|
+
expect(reloaded!.agent.refusalHandling?.primarySummaryFallback).toEqual({
|
|
122
|
+
enabled: true,
|
|
123
|
+
maxNewSummaries: 4,
|
|
124
|
+
requestBudgetTokens: 216_000,
|
|
125
|
+
});
|
|
126
|
+
expect(reloaded!.agent.strategy?.compressionRefusalCurveFallbacks).toBe(2);
|
|
127
|
+
expect(reloaded!.agent.strategy?.compressionContextBudgetTokens).toBe(19_000);
|
|
128
|
+
|
|
129
|
+
const runtimeStrategy = buildFrameworkStrategy(reloaded!, 'model', 'America/Los_Angeles');
|
|
130
|
+
const runtimeConfig = strategyConfigView(runtimeStrategy);
|
|
131
|
+
expect(runtimeConfig.compressionRefusalCurveFallbacks).toBe(2);
|
|
132
|
+
expect(runtimeConfig.compressionContextBudgetTokens).toBe(19_000);
|
|
133
|
+
|
|
134
|
+
const agentConfig = buildFrameworkAgentConfig(reloaded!, 'agent', 'model', runtimeStrategy);
|
|
135
|
+
expect(agentConfig.sameRoundThinkTextPolicy).toBe('private');
|
|
136
|
+
expect(agentConfig.refusalHandling?.primarySummaryFallback).toEqual({
|
|
137
|
+
enabled: true,
|
|
138
|
+
maxNewSummaries: 4,
|
|
139
|
+
requestBudgetTokens: 216_000,
|
|
140
|
+
});
|
|
141
|
+
expect((agentConfig.providerParams as Record<string, unknown>).service_tier).toBe('priority');
|
|
142
|
+
expect(Object.keys(agentConfig.providerParams as Record<string, unknown>)
|
|
143
|
+
.filter((key) => key === 'service_tier')).toHaveLength(1);
|
|
144
|
+
|
|
145
|
+
const otherRecipe = validateRecipe(recipe({
|
|
146
|
+
strategy: {
|
|
147
|
+
type: 'autobiographical',
|
|
148
|
+
compressionRefusalCurveFallbacks: 0,
|
|
149
|
+
compressionContextBudgetTokens: 50_000,
|
|
150
|
+
},
|
|
151
|
+
}));
|
|
152
|
+
const otherStrategy = buildFrameworkStrategy(otherRecipe, 'other-model', 'America/Los_Angeles');
|
|
153
|
+
const otherConfig = strategyConfigView(otherStrategy);
|
|
154
|
+
expect(otherConfig.compressionRefusalCurveFallbacks).toBe(0);
|
|
155
|
+
expect(otherConfig.compressionContextBudgetTokens).toBe(50_000);
|
|
156
|
+
expect(otherConfig).not.toBe(runtimeConfig);
|
|
157
|
+
expect(runtimeConfig.compressionRefusalCurveFallbacks).toBe(2);
|
|
158
|
+
expect(runtimeConfig.compressionContextBudgetTokens).toBe(19_000);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -81,6 +81,7 @@ function makeStrategy(): TestFrontdesk {
|
|
|
81
81
|
targetChunkTokens: 50,
|
|
82
82
|
autoTickOnNewMessage: false,
|
|
83
83
|
maxMessageTokens: 0,
|
|
84
|
+
timeZone: 'UTC',
|
|
84
85
|
});
|
|
85
86
|
}
|
|
86
87
|
|
|
@@ -110,6 +111,15 @@ describe('provenance wrapping', () => {
|
|
|
110
111
|
expect(header!.endsWith('\n')).toBe(true);
|
|
111
112
|
});
|
|
112
113
|
|
|
114
|
+
test('renders provenance time in the configured zone', () => {
|
|
115
|
+
const s = new TestFrontdesk({ timeZone: 'America/Los_Angeles' });
|
|
116
|
+
const m = msg('User', 'hello', {
|
|
117
|
+
serverId: 'zulip',
|
|
118
|
+
timestamp: '2026-07-17T14:32:00Z',
|
|
119
|
+
});
|
|
120
|
+
expect(s.pub_buildHeader(m)).toContain('07:32');
|
|
121
|
+
});
|
|
122
|
+
|
|
113
123
|
test('wrapProvenance prepends header into the first text block', () => {
|
|
114
124
|
const s = makeStrategy();
|
|
115
125
|
const m = msg('User', 'hello', {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
projectItem,
|
|
4
|
+
reconstructEffectiveHistory,
|
|
5
|
+
} from '../scripts/import-codex-rollout.js';
|
|
6
|
+
|
|
7
|
+
describe('Codex rollout import', () => {
|
|
8
|
+
test('uses replacement_history as the authoritative compaction boundary', () => {
|
|
9
|
+
const history = reconstructEffectiveHistory([
|
|
10
|
+
{ type: 'response_item', payload: { type: 'message', id: 'discarded' } },
|
|
11
|
+
{
|
|
12
|
+
type: 'compacted', timestamp: '2026-07-13T00:00:00Z',
|
|
13
|
+
payload: { replacement_history: [
|
|
14
|
+
{ type: 'message', role: 'user', content: 'kept' },
|
|
15
|
+
{ type: 'compaction', id: 'cmp_1', encrypted_content: 'opaque' },
|
|
16
|
+
] },
|
|
17
|
+
},
|
|
18
|
+
{ type: 'response_item', payload: { type: 'reasoning', id: 'rs_2', encrypted_content: 'tail' } },
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
expect(history.map(entry => entry.item.id ?? entry.item.type)).toEqual([
|
|
22
|
+
'message', 'cmp_1', 'rs_2',
|
|
23
|
+
]);
|
|
24
|
+
expect(history[0]!.restoredByCompaction).toBe(true);
|
|
25
|
+
expect(history[2]!.restoredByCompaction).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('keeps the exact native item on the Chronicle projection block', () => {
|
|
29
|
+
const item = {
|
|
30
|
+
type: 'message', id: 'msg_1', role: 'assistant', phase: 'commentary',
|
|
31
|
+
content: [{ type: 'output_text', text: 'hello' }],
|
|
32
|
+
};
|
|
33
|
+
const [block] = projectItem(item);
|
|
34
|
+
expect(block).toMatchObject({ type: 'text', text: 'hello', rawItem: item });
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Off-path refusal dragnet (observability M3): the logging adapter fires
|
|
3
|
+
* onRefusal for refusals on complete() calls — the compression/summarizer
|
|
4
|
+
* path the framework's own noteRefusal never sees — and stays silent for
|
|
5
|
+
* streamed refusals (main driver's job) and non-refusal completions.
|
|
6
|
+
*/
|
|
7
|
+
import { test, expect } from 'bun:test';
|
|
8
|
+
import { LoggingAnthropicAdapter } from '../src/logging-adapter.js';
|
|
9
|
+
|
|
10
|
+
function makeAdapter() {
|
|
11
|
+
const adapter = new LoggingAnthropicAdapter({ apiKey: 'test-key' }, '/tmp/llm-test.jsonl', () => ({ enabled: false, budgetTokens: 0 }));
|
|
12
|
+
const fired: unknown[] = [];
|
|
13
|
+
adapter.onRefusal = (info) => fired.push(info);
|
|
14
|
+
const observe = (kind: 'complete' | 'stream', stopReason?: string, category?: string) =>
|
|
15
|
+
(adapter as unknown as {
|
|
16
|
+
observeCall: (k: string, t: string, d: number, req: unknown, raw: unknown, res?: unknown) => void;
|
|
17
|
+
}).observeCall(kind, new Date().toISOString(), 100,
|
|
18
|
+
{ model: 'claude-fable-5', messages: new Array(37).fill({}) },
|
|
19
|
+
undefined,
|
|
20
|
+
stopReason
|
|
21
|
+
? {
|
|
22
|
+
usage: { inputTokens: 64_000, outputTokens: 3, cacheReadTokens: 0, cacheCreationTokens: 0 },
|
|
23
|
+
stopReason,
|
|
24
|
+
raw: { stop_reason: stopReason, ...(category ? { stop_details: { category } } : {}) },
|
|
25
|
+
}
|
|
26
|
+
: undefined,
|
|
27
|
+
);
|
|
28
|
+
return { adapter, fired, observe };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('complete() refusal fires the dragnet with category + size', () => {
|
|
32
|
+
const { fired, observe } = makeAdapter();
|
|
33
|
+
observe('complete', 'refusal', 'reasoning_extraction');
|
|
34
|
+
expect(fired.length).toBe(1);
|
|
35
|
+
expect(fired[0]).toMatchObject({
|
|
36
|
+
kind: 'complete',
|
|
37
|
+
category: 'reasoning_extraction',
|
|
38
|
+
model: 'claude-fable-5',
|
|
39
|
+
messages: 37,
|
|
40
|
+
inputTokens: 64_000,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('streamed refusal does NOT fire (main driver already escalates those)', () => {
|
|
45
|
+
const { fired, observe } = makeAdapter();
|
|
46
|
+
observe('stream', 'refusal', 'cyber');
|
|
47
|
+
expect(fired.length).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('non-refusal completions and throwing callbacks are harmless', () => {
|
|
51
|
+
const { adapter, fired, observe } = makeAdapter();
|
|
52
|
+
observe('complete', 'end_turn');
|
|
53
|
+
observe('complete'); // error path: no response at all
|
|
54
|
+
expect(fired.length).toBe(0);
|
|
55
|
+
adapter.onRefusal = () => { throw new Error('observer bug'); };
|
|
56
|
+
expect(() => observe('complete', 'refusal', 'cyber')).not.toThrow();
|
|
57
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from 'bun:test';
|
|
2
2
|
import { LoggingAnthropicAdapter } from '../src/logging-adapter.js';
|
|
3
|
+
import type { ProviderCallRecord } from '../src/call-ledger.js';
|
|
3
4
|
import type { ProviderRequest, ProviderResponse } from '@animalabs/membrane';
|
|
4
5
|
|
|
5
6
|
// Regression guard for the reasoning passthrough. `withReasoning` injects
|
|
@@ -57,7 +58,7 @@ describe('LoggingAnthropicAdapter.withReasoning', () => {
|
|
|
57
58
|
describe('LoggingAnthropicAdapter request logging', () => {
|
|
58
59
|
const adapter = new LoggingAnthropicAdapter({ apiKey: 'test' }, '/dev/null');
|
|
59
60
|
const internals = adapter as unknown as {
|
|
60
|
-
requestSummary(r: ProviderRequest): Record<string, unknown>;
|
|
61
|
+
requestSummary(r: ProviderRequest, raw?: unknown): Record<string, unknown>;
|
|
61
62
|
refusalRawRequest(r: ProviderResponse, raw: unknown): unknown;
|
|
62
63
|
};
|
|
63
64
|
|
|
@@ -76,6 +77,20 @@ describe('LoggingAnthropicAdapter request logging', () => {
|
|
|
76
77
|
});
|
|
77
78
|
});
|
|
78
79
|
|
|
80
|
+
test('summarizes provider cache markers without retaining prompt content', () => {
|
|
81
|
+
const raw = {
|
|
82
|
+
messages: [{
|
|
83
|
+
role: 'user',
|
|
84
|
+
content: [{ type: 'text', text: 'do not log me', cache_control: { type: 'ephemeral', ttl: '1h' } }],
|
|
85
|
+
}],
|
|
86
|
+
};
|
|
87
|
+
expect(internals.requestSummary(baseRequest, raw)).toMatchObject({
|
|
88
|
+
cacheBreakpoints: 1,
|
|
89
|
+
cacheTtls: ['1h'],
|
|
90
|
+
});
|
|
91
|
+
expect(JSON.stringify(internals.requestSummary(baseRequest, raw))).not.toContain('do not log me');
|
|
92
|
+
});
|
|
93
|
+
|
|
79
94
|
test('retains the raw request only for refusals', () => {
|
|
80
95
|
const rawRequest = { messages: ['forensic context'] };
|
|
81
96
|
const success = { raw: { stop_reason: 'end_turn' } } as unknown as ProviderResponse;
|
|
@@ -84,4 +99,46 @@ describe('LoggingAnthropicAdapter request logging', () => {
|
|
|
84
99
|
expect(internals.refusalRawRequest(success, rawRequest)).toBeUndefined();
|
|
85
100
|
expect(internals.refusalRawRequest(refusal, rawRequest)).toBe(rawRequest);
|
|
86
101
|
});
|
|
102
|
+
|
|
103
|
+
test('forwards authoritative billing buckets from the provider response', () => {
|
|
104
|
+
const calls: ProviderCallRecord[] = [];
|
|
105
|
+
const observed = new LoggingAnthropicAdapter(
|
|
106
|
+
{ apiKey: 'test' },
|
|
107
|
+
'/dev/null',
|
|
108
|
+
undefined,
|
|
109
|
+
(call) => calls.push(call),
|
|
110
|
+
) as unknown as {
|
|
111
|
+
observeCall(
|
|
112
|
+
kind: 'complete' | 'stream',
|
|
113
|
+
timestamp: string,
|
|
114
|
+
durationMs: number,
|
|
115
|
+
request: ProviderRequest,
|
|
116
|
+
rawRequest: unknown,
|
|
117
|
+
response: ProviderResponse,
|
|
118
|
+
): void;
|
|
119
|
+
};
|
|
120
|
+
const response = {
|
|
121
|
+
usage: { inputTokens: 2, outputTokens: 10, cacheCreationTokens: 100, cacheReadTokens: 50 },
|
|
122
|
+
raw: {
|
|
123
|
+
usage: {
|
|
124
|
+
cache_creation: {
|
|
125
|
+
ephemeral_5m_input_tokens: 25,
|
|
126
|
+
ephemeral_1h_input_tokens: 75,
|
|
127
|
+
},
|
|
128
|
+
service_tier: 'standard',
|
|
129
|
+
inference_geo: 'global',
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
} as unknown as ProviderResponse;
|
|
133
|
+
|
|
134
|
+
observed.observeCall('stream', '2026-07-13T00:00:00Z', 10, baseRequest, {}, response);
|
|
135
|
+
expect(calls[0]).toMatchObject({
|
|
136
|
+
cacheWriteTokens: 100,
|
|
137
|
+
cacheWrite5mTokens: 25,
|
|
138
|
+
cacheWrite1hTokens: 75,
|
|
139
|
+
cacheWriteBucketsAuthoritative: true,
|
|
140
|
+
serviceTier: 'standard',
|
|
141
|
+
inferenceGeo: 'global',
|
|
142
|
+
});
|
|
143
|
+
});
|
|
87
144
|
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* inference).
|
|
5
5
|
*/
|
|
6
6
|
import { describe, test, expect } from 'bun:test';
|
|
7
|
-
import { validateRecipe } from '../src/recipe.js';
|
|
7
|
+
import { DEFAULT_RECIPE, validateRecipe } from '../src/recipe.js';
|
|
8
8
|
|
|
9
9
|
function recipeWithCacheTtl(cacheTtl?: unknown) {
|
|
10
10
|
return {
|
|
@@ -17,6 +17,10 @@ function recipeWithCacheTtl(cacheTtl?: unknown) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
describe('recipe agent.cacheTtl validation', () => {
|
|
20
|
+
test('the shipped default recipe explicitly uses "1h"', () => {
|
|
21
|
+
expect(DEFAULT_RECIPE.agent.cacheTtl).toBe('1h');
|
|
22
|
+
});
|
|
23
|
+
|
|
20
24
|
test('accepts "5m"', () => {
|
|
21
25
|
expect(validateRecipe(recipeWithCacheTtl('5m')).agent.cacheTtl).toBe('5m');
|
|
22
26
|
});
|
|
@@ -25,8 +29,8 @@ describe('recipe agent.cacheTtl validation', () => {
|
|
|
25
29
|
expect(validateRecipe(recipeWithCacheTtl('1h')).agent.cacheTtl).toBe('1h');
|
|
26
30
|
});
|
|
27
31
|
|
|
28
|
-
test('
|
|
29
|
-
expect(validateRecipe(recipeWithCacheTtl()).agent.cacheTtl).
|
|
32
|
+
test('defaults unset cacheTtl to "1h"', () => {
|
|
33
|
+
expect(validateRecipe(recipeWithCacheTtl()).agent.cacheTtl).toBe('1h');
|
|
30
34
|
});
|
|
31
35
|
|
|
32
36
|
test('rejects a typo\'d TTL at load time', () => {
|