@animalabs/connectome-host 0.7.2 → 0.7.4
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 +203 -10
- package/HEADLESS-FLEET-PLAN.md +22 -0
- package/README.md +22 -11
- package/docs/AGENT-ONBOARDING.md +20 -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 +3 -3
- package/scripts/audit-module-optins.ts +288 -0
- 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 +33 -3
- package/src/headless.ts +14 -0
- package/src/index.ts +95 -35
- package/src/logging-adapter.ts +13 -2
- package/src/mcpl-config.ts +8 -0
- package/src/modules/fleet-module.ts +60 -1
- package/src/modules/fleet-types.ts +30 -1
- package/src/modules/identity-module.ts +274 -0
- package/src/modules/mcpl-admin-module.ts +78 -5
- package/src/modules/observers-module.ts +12 -0
- package/src/modules/retrieval-module.ts +254 -52
- package/src/modules/retrieval-trace-page.ts +254 -0
- package/src/modules/retrieval-trace.ts +904 -0
- package/src/modules/settings-module.ts +28 -2
- package/src/modules/subscription-gc-module.ts +54 -1
- package/src/modules/tts-relay-module.ts +33 -18
- package/src/modules/web-ui-module.ts +445 -894
- package/src/recipe.ts +137 -12
- 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 +1187 -0
- package/src/web/protocol.ts +75 -10
- package/test/audit-module-optins.test.ts +167 -0
- package/test/bedrock-prompt-caching.test.ts +170 -0
- package/test/fleet-panel-request.test.ts +90 -0
- package/test/framework-strategy-defaults.test.ts +110 -0
- package/test/frontdesk-strategy.test.ts +25 -37
- package/test/headless-panel-request.test.ts +201 -0
- package/test/identity-and-surfaces.test.ts +157 -0
- package/test/mcpl-admin-module.test.ts +23 -0
- package/test/mock-headless-child.ts +14 -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/subscription-gc-module.test.ts +152 -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/web/bun.lock +345 -0
- package/web/src/App.tsx +159 -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/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
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report-only audit of subagents/lessons/retrieval opt-ins across recipes.
|
|
3
|
+
*
|
|
4
|
+
* Background: published v0.7.2 and earlier treated these three modules as
|
|
5
|
+
* opt-OUT (an omitted `modules` key meant enabled), and DEFAULT_RECIPE
|
|
6
|
+
* enabled all three explicitly. Current main treats them as opt-IN
|
|
7
|
+
* (a4bd9fd). That flip is the right default, but it leaves existing
|
|
8
|
+
* deployments with two things only a human can settle:
|
|
9
|
+
*
|
|
10
|
+
* 1. A source recipe that explicitly says `lessons: true` stays enabled
|
|
11
|
+
* after upgrade — intentionally. Whether that `true` was a real choice
|
|
12
|
+
* or boilerplate copied from the old onboarding guide is not something
|
|
13
|
+
* a defaults change (or this script) can infer. It gets reported;
|
|
14
|
+
* the operator decides.
|
|
15
|
+
* 2. A persisted `data/.recipe.json` is a resolved snapshot of whatever
|
|
16
|
+
* was in effect at launch — under the old defaults that includes
|
|
17
|
+
* `subagents/lessons/retrieval: true` the operator never wrote. It is
|
|
18
|
+
* not necessarily the authoritative source recipe, so it's reported
|
|
19
|
+
* separately, as a pointer back to the source, never as a finding in
|
|
20
|
+
* itself.
|
|
21
|
+
*
|
|
22
|
+
* This script reads and reports. It never modifies a file, and it has no
|
|
23
|
+
* flag that would make it modify a file.
|
|
24
|
+
*
|
|
25
|
+
* Usage:
|
|
26
|
+
* bun scripts/audit-module-optins.ts <recipe.json | directory> [...more]
|
|
27
|
+
*
|
|
28
|
+
* Directories are scanned recursively for *.json (including .recipe.json
|
|
29
|
+
* snapshots; node_modules/.git skipped). Fleet children referenced by
|
|
30
|
+
* local path are followed automatically.
|
|
31
|
+
*
|
|
32
|
+
* Exit codes: 0 = nothing needs an operator decision; 2 = explicit enables
|
|
33
|
+
* (or inert-retrieval combinations) found — review the report; 1 = a path
|
|
34
|
+
* argument could not be read.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
38
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
39
|
+
|
|
40
|
+
export const AUDITED_MODULES = ['subagents', 'lessons', 'retrieval'] as const;
|
|
41
|
+
export type AuditedModule = (typeof AUDITED_MODULES)[number];
|
|
42
|
+
|
|
43
|
+
export type ModuleState = 'explicit-enable' | 'explicit-disable' | 'omitted';
|
|
44
|
+
|
|
45
|
+
export interface RecipeAudit {
|
|
46
|
+
/** Path as reported (relative to cwd where possible). */
|
|
47
|
+
path: string;
|
|
48
|
+
recipeName: string;
|
|
49
|
+
/** basename === '.recipe.json': a resolved launch snapshot, not a source. */
|
|
50
|
+
isSnapshot: boolean;
|
|
51
|
+
states: Record<AuditedModule, ModuleState>;
|
|
52
|
+
/** Snapshot has all three explicitly true — the old DEFAULT_RECIPE shape.
|
|
53
|
+
* Almost certainly captured pre-flip defaults, not an operator choice. */
|
|
54
|
+
matchesOldDefaultBoilerplate: boolean;
|
|
55
|
+
/** retrieval explicitly enabled while lessons is omitted: worked under the
|
|
56
|
+
* old defaults (omitted lessons = on), silently inert after upgrade. */
|
|
57
|
+
inertRetrieval: boolean;
|
|
58
|
+
/** Local fleet-children recipe paths referenced by this recipe. */
|
|
59
|
+
childRecipePaths: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface RawRecipeShape {
|
|
63
|
+
name?: unknown;
|
|
64
|
+
agent?: unknown;
|
|
65
|
+
modules?: Record<string, unknown>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A JSON document we treat as a recipe: object with a name and an agent
|
|
69
|
+
* block. Anything else in a scanned directory is silently skipped. */
|
|
70
|
+
export function looksLikeRecipe(raw: unknown): raw is RawRecipeShape {
|
|
71
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return false;
|
|
72
|
+
const o = raw as RawRecipeShape;
|
|
73
|
+
return typeof o.name === 'string' && !!o.agent && typeof o.agent === 'object';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function classifyModule(value: unknown): ModuleState {
|
|
77
|
+
if (value === undefined || value === null) return 'omitted';
|
|
78
|
+
if (value === false) return 'explicit-disable';
|
|
79
|
+
// true or a config object both enable (matches createFramework wiring).
|
|
80
|
+
return 'explicit-enable';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function auditRecipe(raw: RawRecipeShape, path: string): RecipeAudit {
|
|
84
|
+
const modules = (raw.modules && typeof raw.modules === 'object' ? raw.modules : {}) as Record<string, unknown>;
|
|
85
|
+
const states = Object.fromEntries(
|
|
86
|
+
AUDITED_MODULES.map((m) => [m, classifyModule(modules[m])]),
|
|
87
|
+
) as Record<AuditedModule, ModuleState>;
|
|
88
|
+
|
|
89
|
+
const isSnapshot = basename(path) === '.recipe.json';
|
|
90
|
+
|
|
91
|
+
const childRecipePaths: string[] = [];
|
|
92
|
+
const fleet = modules.fleet;
|
|
93
|
+
if (fleet && typeof fleet === 'object') {
|
|
94
|
+
const children = (fleet as { children?: unknown }).children;
|
|
95
|
+
if (Array.isArray(children)) {
|
|
96
|
+
for (const child of children) {
|
|
97
|
+
const ref = (child as { recipe?: unknown })?.recipe;
|
|
98
|
+
if (typeof ref !== 'string' || !ref) continue;
|
|
99
|
+
if (ref.startsWith('http://') || ref.startsWith('https://')) continue;
|
|
100
|
+
childRecipePaths.push(isAbsolute(ref) ? ref : resolve(dirname(resolve(path)), ref));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
path,
|
|
107
|
+
recipeName: String(raw.name),
|
|
108
|
+
isSnapshot,
|
|
109
|
+
states,
|
|
110
|
+
matchesOldDefaultBoilerplate:
|
|
111
|
+
isSnapshot &&
|
|
112
|
+
modules.subagents === true && modules.lessons === true && modules.retrieval === true,
|
|
113
|
+
inertRetrieval: states.retrieval === 'explicit-enable' && states.lessons === 'omitted',
|
|
114
|
+
childRecipePaths,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Filesystem walk (main-path only; the logic above is what the tests pin)
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'web']);
|
|
123
|
+
|
|
124
|
+
function collectJsonFiles(root: string, out: string[]): void {
|
|
125
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
126
|
+
const full = join(root, entry.name);
|
|
127
|
+
if (entry.isDirectory()) {
|
|
128
|
+
if (!SKIP_DIRS.has(entry.name)) collectJsonFiles(full, out);
|
|
129
|
+
} else if (entry.name.endsWith('.json')) {
|
|
130
|
+
out.push(full);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function auditPaths(paths: string[]): { audits: RecipeAudit[]; unreadable: string[] } {
|
|
136
|
+
const files: string[] = [];
|
|
137
|
+
const unreadable: string[] = [];
|
|
138
|
+
for (const p of paths) {
|
|
139
|
+
if (!existsSync(p)) {
|
|
140
|
+
unreadable.push(p);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (statSync(p).isDirectory()) collectJsonFiles(p, files);
|
|
144
|
+
else files.push(p);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const audits: RecipeAudit[] = [];
|
|
148
|
+
const seen = new Set<string>();
|
|
149
|
+
const queue = [...files];
|
|
150
|
+
while (queue.length > 0) {
|
|
151
|
+
const file = queue.shift()!;
|
|
152
|
+
const key = resolve(file);
|
|
153
|
+
if (seen.has(key)) continue;
|
|
154
|
+
seen.add(key);
|
|
155
|
+
let raw: unknown;
|
|
156
|
+
try {
|
|
157
|
+
raw = JSON.parse(readFileSync(file, 'utf-8'));
|
|
158
|
+
} catch {
|
|
159
|
+
continue; // not JSON we can read — not our business to complain about
|
|
160
|
+
}
|
|
161
|
+
if (!looksLikeRecipe(raw)) continue;
|
|
162
|
+
const audit = auditRecipe(raw, file);
|
|
163
|
+
audits.push(audit);
|
|
164
|
+
// Follow local fleet children so a parent path argument covers the tree.
|
|
165
|
+
for (const child of audit.childRecipePaths) {
|
|
166
|
+
if (existsSync(child)) queue.push(child);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { audits, unreadable };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Report
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
const STATE_LINES: Record<AuditedModule, Record<ModuleState, string>> = {
|
|
177
|
+
subagents: {
|
|
178
|
+
'explicit-enable':
|
|
179
|
+
'explicitly enabled — stays on after upgrade. Keep if this agent really forks workers.',
|
|
180
|
+
'explicit-disable': 'explicitly disabled — no change on upgrade (belt-and-braces for old checkouts).',
|
|
181
|
+
omitted:
|
|
182
|
+
'omitted — ON under published ≤0.7.2, OFF after upgrade. Declare `true` only if this agent relied on forking.',
|
|
183
|
+
},
|
|
184
|
+
lessons: {
|
|
185
|
+
'explicit-enable':
|
|
186
|
+
'explicitly enabled — stays on after upgrade. Keep if this agent curates a lesson library.',
|
|
187
|
+
'explicit-disable': 'explicitly disabled — no change on upgrade (belt-and-braces for old checkouts).',
|
|
188
|
+
omitted:
|
|
189
|
+
'omitted — ON under published ≤0.7.2, OFF after upgrade. Declare `true` only if this agent relied on lessons.',
|
|
190
|
+
},
|
|
191
|
+
retrieval: {
|
|
192
|
+
'explicit-enable':
|
|
193
|
+
'explicitly enabled — stays on after upgrade: per-compile injection plus two Haiku calls per turn. Keep only as a real choice.',
|
|
194
|
+
'explicit-disable': 'explicitly disabled — no change on upgrade (belt-and-braces for old checkouts).',
|
|
195
|
+
omitted: 'omitted — ON under published ≤0.7.2 (when lessons ran), OFF after upgrade.',
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export function renderReport(audits: RecipeAudit[]): { text: string; needsDecision: number } {
|
|
200
|
+
const lines: string[] = [];
|
|
201
|
+
let needsDecision = 0;
|
|
202
|
+
|
|
203
|
+
const sources = audits.filter((a) => !a.isSnapshot);
|
|
204
|
+
const snapshots = audits.filter((a) => a.isSnapshot);
|
|
205
|
+
|
|
206
|
+
for (const a of sources) {
|
|
207
|
+
lines.push(`${a.path} (recipe "${a.recipeName}")`);
|
|
208
|
+
for (const m of AUDITED_MODULES) {
|
|
209
|
+
const state = a.states[m];
|
|
210
|
+
if (state === 'explicit-enable') needsDecision++;
|
|
211
|
+
lines.push(` ${m}: ${STATE_LINES[m][state]}`);
|
|
212
|
+
}
|
|
213
|
+
if (a.inertRetrieval) {
|
|
214
|
+
needsDecision++;
|
|
215
|
+
lines.push(
|
|
216
|
+
' ⚠ retrieval is enabled but lessons is omitted. Under the old defaults omitted lessons still ran,',
|
|
217
|
+
' so retrieval worked; after upgrade lessons is off and retrieval is silently inert.',
|
|
218
|
+
' Either add `lessons: true` (if retrieval is a real choice) or drop `retrieval`.',
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
lines.push('');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (snapshots.length > 0) {
|
|
225
|
+
lines.push('Resolved snapshots (data/.recipe.json) — informational only:');
|
|
226
|
+
lines.push(
|
|
227
|
+
' These are launch-time captures, not authoritative sources. Under the old defaults they',
|
|
228
|
+
' include enables the operator never wrote. Audit the source recipe each run was launched',
|
|
229
|
+
' from; the snapshot refreshes on the next launch from source.',
|
|
230
|
+
);
|
|
231
|
+
for (const a of snapshots) {
|
|
232
|
+
const enabled = AUDITED_MODULES.filter((m) => a.states[m] === 'explicit-enable');
|
|
233
|
+
const note = a.matchesOldDefaultBoilerplate
|
|
234
|
+
? 'all three enabled — matches the old DEFAULT_RECIPE shape, almost certainly pre-flip defaults, not a choice'
|
|
235
|
+
: enabled.length > 0
|
|
236
|
+
? `explicitly enabled here: ${enabled.join(', ')}`
|
|
237
|
+
: 'no audited modules enabled';
|
|
238
|
+
lines.push(` ${a.path} (recipe "${a.recipeName}"): ${note}`);
|
|
239
|
+
}
|
|
240
|
+
lines.push('');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { text: lines.join('\n'), needsDecision };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Main
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
function main(): void {
|
|
251
|
+
const args = process.argv.slice(2).filter((a) => !a.startsWith('--'));
|
|
252
|
+
if (args.length === 0) {
|
|
253
|
+
console.error('Usage: bun scripts/audit-module-optins.ts <recipe.json | directory> [...more]');
|
|
254
|
+
console.error('Report-only: reads recipes, changes nothing.');
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const { audits, unreadable } = auditPaths(args);
|
|
259
|
+
for (const p of unreadable) console.error(`cannot read: ${p}`);
|
|
260
|
+
|
|
261
|
+
console.log('connectome-host module opt-in audit — report only, nothing is modified\n');
|
|
262
|
+
console.log(
|
|
263
|
+
'Published ≤0.7.2 treated subagents/lessons/retrieval as opt-OUT (omitted = enabled).\n' +
|
|
264
|
+
'Current main treats them as opt-IN (omitted = disabled). Explicit enables survive the\n' +
|
|
265
|
+
'upgrade by design — this report shows where each recipe stands so you can decide which\n' +
|
|
266
|
+
'of those are real choices and which are old boilerplate.\n',
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
let needsDecision = 0;
|
|
270
|
+
if (audits.length === 0) {
|
|
271
|
+
console.log('No recipe-shaped JSON found under the given paths.');
|
|
272
|
+
} else {
|
|
273
|
+
const report = renderReport(audits);
|
|
274
|
+
needsDecision = report.needsDecision;
|
|
275
|
+
console.log(report.text);
|
|
276
|
+
const sources = audits.filter((a) => !a.isSnapshot).length;
|
|
277
|
+
console.log(
|
|
278
|
+
`Summary: ${audits.length} recipe(s) audited (${sources} source, ${audits.length - sources} snapshot), ` +
|
|
279
|
+
`${needsDecision} item(s) need an operator decision.`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Unreadable path arguments outrank findings in the exit code.
|
|
284
|
+
if (unreadable.length > 0) process.exit(1);
|
|
285
|
+
if (needsDecision > 0) process.exit(2);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (import.meta.main) main();
|
|
@@ -42,7 +42,7 @@ import { resolve } from 'node:path';
|
|
|
42
42
|
import { JsStore } from '@animalabs/chronicle';
|
|
43
43
|
import { ContextManager } from '@animalabs/context-manager';
|
|
44
44
|
import { AutobiographicalStrategy } from '@animalabs/agent-framework';
|
|
45
|
-
import { Membrane, AnthropicAdapter, type NormalizedResponse } from '@animalabs/membrane';
|
|
45
|
+
import { Membrane, AnthropicAdapter, BedrockAdapter, type NormalizedResponse } from '@animalabs/membrane';
|
|
46
46
|
import { SessionManager } from '../src/session-manager.js';
|
|
47
47
|
import { resolveAgentName } from '../src/agent-name.js';
|
|
48
48
|
|
|
@@ -181,7 +181,13 @@ function formatDuration(sec: number): string {
|
|
|
181
181
|
|
|
182
182
|
async function main() {
|
|
183
183
|
const opts = parseArgs(process.argv);
|
|
184
|
-
|
|
184
|
+
const bedrockModel = /^([a-z]{2,6}\.)?anthropic\./.test(opts.model);
|
|
185
|
+
if (bedrockModel) {
|
|
186
|
+
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) {
|
|
187
|
+
console.error('Bedrock model id — set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (and AWS_REGION)');
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
} else if (!process.env.ANTHROPIC_API_KEY) {
|
|
185
191
|
console.error('Set ANTHROPIC_API_KEY');
|
|
186
192
|
process.exit(1);
|
|
187
193
|
}
|
|
@@ -218,8 +224,16 @@ async function main() {
|
|
|
218
224
|
// -- Membrane with token-spend hook --
|
|
219
225
|
const price = priceOf(opts.model);
|
|
220
226
|
const spend: Spend = { inputTokens: 0, outputTokens: 0, cost: 0 };
|
|
221
|
-
|
|
227
|
+
// Bedrock model ids (anthropic.* / <region>.anthropic.*) route through the
|
|
228
|
+
// BedrockAdapter with AWS_* env creds — legacy Claude models (revival
|
|
229
|
+
// sessions) exist nowhere else. Caching off: legacy models reject
|
|
230
|
+
// cache_control outright.
|
|
231
|
+
const isBedrock = /^([a-z]{2,6}\.)?anthropic\./.test(opts.model);
|
|
232
|
+
const adapter = isBedrock
|
|
233
|
+
? new BedrockAdapter()
|
|
234
|
+
: new AnthropicAdapter({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
222
235
|
const membrane = new Membrane(adapter, {
|
|
236
|
+
...(isBedrock ? { defaultPromptCaching: false } : {}),
|
|
223
237
|
// Imported sessions store assistant turns under participant `agentName`.
|
|
224
238
|
// Membrane's default assistantParticipant is 'Claude'; if that disagrees
|
|
225
239
|
// with the stored participant every assistant message maps to role
|
|
@@ -532,7 +532,19 @@ export class CodexSubscriptionAdapter implements ProviderAdapter {
|
|
|
532
532
|
stopReason,
|
|
533
533
|
stopSequence: undefined,
|
|
534
534
|
usage: {
|
|
535
|
-
|
|
535
|
+
// OpenAI/Codex reports `input_tokens` INCLUSIVE of cached tokens,
|
|
536
|
+
// whereas the rest of the stack uses the additive Anthropic
|
|
537
|
+
// convention (inputTokens = fresh/uncached, cacheReadTokens added on
|
|
538
|
+
// top, so inputTokens + cacheReadTokens == total prompt). Report
|
|
539
|
+
// fresh-only here so that convention holds. Without this, every
|
|
540
|
+
// consumer that sums the buckets — the calibration realTotal
|
|
541
|
+
// (framework.ts), cost pricing, and gate metering — double-counts
|
|
542
|
+
// the cached prefix: on a heavily-cached turn real/est hit ~2.0,
|
|
543
|
+
// which the estimator rejected as out-of-band on full-cache compiles
|
|
544
|
+
// and (worse) ratcheted the multiplier upward on partial-cache ones,
|
|
545
|
+
// inflating estimates until the budget solver exhausted and the
|
|
546
|
+
// agent wedged (Sol, 2026-07-31).
|
|
547
|
+
inputTokens: Math.max(0, (terminal.usage?.input_tokens ?? 0) - cachedTokens),
|
|
536
548
|
outputTokens: terminal.usage?.output_tokens ?? 0,
|
|
537
549
|
cacheReadTokens: cachedTokens > 0 ? cachedTokens : undefined,
|
|
538
550
|
},
|
|
@@ -10,12 +10,60 @@ export type FrameworkAgentConfig = AgentConfig & {
|
|
|
10
10
|
sameRoundThinkTextPolicy?: 'public' | 'private';
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Prompt caching went GA on Bedrock in April 2025 for 3.5 Haiku, 3.7
|
|
15
|
+
* Sonnet, and Claude 4+ — but NOT for 3.5 Sonnet (either version). 1022
|
|
16
|
+
* ("3.6") was in the Dec 2024 preview and was dropped at GA — that
|
|
17
|
+
* account-level "your request did not allow prompt caching" is the error
|
|
18
|
+
* observed here 2026-07-21 (antra's diagnosis, confirmed against the AWS
|
|
19
|
+
* docs 2026-07-31; as of the same day's live probe every 3.5-era model
|
|
20
|
+
* is EOL on Bedrock anyway). So the gate denies the pre-GA FAMILIES —
|
|
21
|
+
* Claude v2/instant, Claude 3, 3.5 Sonnet — at the family boundary, so
|
|
22
|
+
* dated ids, bare aliases, -latest, and inference-profile forms
|
|
23
|
+
* (us.anthropic.claude-...) all resolve the same; 3.5 Haiku and 3.7
|
|
24
|
+
* Sonnet stay distinct and on. Non-Claude Bedrock ids (Nova etc.) are
|
|
25
|
+
* out of scope for this gate and conservatively off — membrane's
|
|
26
|
+
* BedrockAdapter only accepts Claude ids today. recipe.agent.
|
|
27
|
+
* promptCaching overrides in either direction for accounts/regions
|
|
28
|
+
* whose entitlements differ from the GA table. (Connectome issue #35.)
|
|
29
|
+
*/
|
|
30
|
+
export function bedrockModelSupportsPromptCaching(model: string): boolean {
|
|
31
|
+
const id = model.toLowerCase();
|
|
32
|
+
if (!id.includes('claude')) return false;
|
|
33
|
+
// (?![a-z0-9]) = family boundary: end of id, or a separator (-, ., :)
|
|
34
|
+
// before a date/qualifier — matches the whole family, not one spelling.
|
|
35
|
+
return !/claude-(v2|instant|3-(opus|sonnet|haiku)|3-5-sonnet)(?![a-z0-9])/.test(id);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resolvePromptCaching(recipe: Recipe, model: string): boolean | undefined {
|
|
39
|
+
if (recipe.agent.promptCaching !== undefined) return recipe.agent.promptCaching;
|
|
40
|
+
if (recipe.agent.provider === 'bedrock') return bedrockModelSupportsPromptCaching(model);
|
|
41
|
+
return undefined; // membrane default (on)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Membrane-level counterpart of resolvePromptCaching, spread into the
|
|
46
|
+
* Membrane constructor config. The per-agent flag only governs agent
|
|
47
|
+
* inference; internal callers (autobio compression, executeMerge) read
|
|
48
|
+
* Membrane's defaultPromptCaching — so an explicit recipe override must
|
|
49
|
+
* land at BOTH layers, on every provider, or `promptCaching: false` on
|
|
50
|
+
* an Anthropic recipe would silently keep caching on for internal calls.
|
|
51
|
+
*/
|
|
52
|
+
export function membraneCachingOverride(
|
|
53
|
+
recipe: Recipe,
|
|
54
|
+
model: string,
|
|
55
|
+
): { defaultPromptCaching?: boolean } {
|
|
56
|
+
const promptCaching = resolvePromptCaching(recipe, model);
|
|
57
|
+
return promptCaching === undefined ? {} : { defaultPromptCaching: promptCaching };
|
|
58
|
+
}
|
|
59
|
+
|
|
13
60
|
export function buildFrameworkAgentConfig(
|
|
14
61
|
recipe: Recipe,
|
|
15
62
|
agentName: string,
|
|
16
63
|
model: string,
|
|
17
64
|
strategy: FrameworkAgentConfig['strategy'],
|
|
18
65
|
): FrameworkAgentConfig {
|
|
66
|
+
const promptCaching = resolvePromptCaching(recipe, model);
|
|
19
67
|
return {
|
|
20
68
|
name: agentName,
|
|
21
69
|
model,
|
|
@@ -23,10 +71,17 @@ export function buildFrameworkAgentConfig(
|
|
|
23
71
|
maxTokens: recipe.agent.maxTokens ?? 16384,
|
|
24
72
|
maxStreamTokens: recipe.agent.maxStreamTokens ?? 150000,
|
|
25
73
|
contextBudgetTokens: recipe.agent.contextBudgetTokens,
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
74
|
+
// cacheTtl is withheld at the HOST layer on bedrock: the transport
|
|
75
|
+
// only has the default 5m cache, and older membrane releases forward
|
|
76
|
+
// the ttl field Bedrock rejects. Note this is not the whole story —
|
|
77
|
+
// Agent Framework still supplies its own default ('1h') downstream
|
|
78
|
+
// when the host omits the field, and membrane ≥0.5.77 strips it at
|
|
79
|
+
// the provider boundary before wire dispatch. Requests are safe, but
|
|
80
|
+
// pre-adapter config is NOT cache-TTL telemetry; the wire truth lives
|
|
81
|
+
// at the adapter.
|
|
82
|
+
...(recipe.agent.cacheTtl && recipe.agent.provider !== 'bedrock'
|
|
83
|
+
&& { cacheTtl: recipe.agent.cacheTtl }),
|
|
84
|
+
...(promptCaching !== undefined && { promptCaching }),
|
|
30
85
|
// Prefill scaffold (anthropic-xml formatter), e.g. chapterx CLI-sim's
|
|
31
86
|
// '<cmd>cat untitled.txt</cmd>' — part of migrating prefill-era bots.
|
|
32
87
|
...(recipe.agent.prefillUserMessage && { prefillUserMessage: recipe.agent.prefillUserMessage }),
|
|
@@ -36,6 +36,7 @@ const PASSTHROUGH_KEYS: ReadonlyArray<keyof RecipeStrategy> = [
|
|
|
36
36
|
'summaryContextLabel',
|
|
37
37
|
'witnessedBeforeSequence',
|
|
38
38
|
'witnessedInstruction',
|
|
39
|
+
'identityReminder',
|
|
39
40
|
];
|
|
40
41
|
|
|
41
42
|
export function buildFrameworkStrategy(
|
|
@@ -82,12 +83,41 @@ export function buildFrameworkStrategy(
|
|
|
82
83
|
if (value !== undefined) autobiographicalOpts[key] = value;
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
// Autobiographical agents default to adaptive resolution
|
|
86
|
-
// opts out
|
|
87
|
-
|
|
86
|
+
// Autobiographical AND frontdesk agents default to adaptive resolution
|
|
87
|
+
// unless a recipe opts out. Frontdesk historically kept the hierarchical
|
|
88
|
+
// renderer; that geometry has no tail reservation and no way to shed
|
|
89
|
+
// summary mass, so a long-lived agent saturates a fixed budget into a
|
|
90
|
+
// terminal context refusal (2026-08-03 boter clerk outage). The adaptive
|
|
91
|
+
// picker solves a frontier to fit — recipes can still pin
|
|
92
|
+
// `adaptiveResolution: false` as a rollback lever.
|
|
93
|
+
if (
|
|
94
|
+
(strategyType === 'autobiographical' || strategyType === 'frontdesk') &&
|
|
95
|
+
autobiographicalOpts.adaptiveResolution === undefined
|
|
96
|
+
) {
|
|
88
97
|
autobiographicalOpts.adaptiveResolution = true;
|
|
89
98
|
}
|
|
90
99
|
|
|
100
|
+
// Reasonable memory defaults for recipes that omit strategy tuning:
|
|
101
|
+
//
|
|
102
|
+
// - foldingStrategy 'kv-stable': the library's own fallback is
|
|
103
|
+
// 'flat-profile', which replans compile layouts without regard for
|
|
104
|
+
// prompt-cache stability. Long-lived agents want cache-stable folds by
|
|
105
|
+
// default; recipes can still pin 'flat-profile'/'oldest-first' explicitly.
|
|
106
|
+
// (Only meaningful under adaptive resolution, so gate on it.)
|
|
107
|
+
// - summaryParticipant <agent name>: the library falls back to the literal
|
|
108
|
+
// 'Claude', which voices self-recollections as a stranger for any agent
|
|
109
|
+
// not named Claude. Summaries should speak as the agent itself.
|
|
110
|
+
if (
|
|
111
|
+
(strategyType === 'autobiographical' || strategyType === 'frontdesk') &&
|
|
112
|
+
autobiographicalOpts.adaptiveResolution !== false &&
|
|
113
|
+
autobiographicalOpts.foldingStrategy === undefined
|
|
114
|
+
) {
|
|
115
|
+
autobiographicalOpts.foldingStrategy = 'kv-stable';
|
|
116
|
+
}
|
|
117
|
+
if (autobiographicalOpts.summaryParticipant === undefined && recipe.agent.name) {
|
|
118
|
+
autobiographicalOpts.summaryParticipant = recipe.agent.name;
|
|
119
|
+
}
|
|
120
|
+
|
|
91
121
|
return strategyType === 'passthrough'
|
|
92
122
|
? new PassthroughStrategy()
|
|
93
123
|
: strategyType === 'frontdesk'
|
package/src/headless.ts
CHANGED
|
@@ -136,6 +136,8 @@ export async function runHeadless(app: AppContext, argv: string[] = []): Promise
|
|
|
136
136
|
'workspace-mounts-snapshot',
|
|
137
137
|
'workspace-tree-snapshot',
|
|
138
138
|
'workspace-file-snapshot',
|
|
139
|
+
'cancel-subagent-result',
|
|
140
|
+
'panel-response',
|
|
139
141
|
]);
|
|
140
142
|
|
|
141
143
|
function emit(event: Record<string, unknown>): void {
|
|
@@ -329,6 +331,18 @@ export async function runHeadless(app: AppContext, argv: string[] = []): Promise
|
|
|
329
331
|
}
|
|
330
332
|
return;
|
|
331
333
|
}
|
|
334
|
+
case 'panel-request': {
|
|
335
|
+
// Operator-panel query/mutation, shared verb. The SAME handler the
|
|
336
|
+
// WebUI host runs locally answers here, so parent and child views of
|
|
337
|
+
// any panel op can never drift. runPanelOp never throws — failures
|
|
338
|
+
// come back as {ok:false} and still produce a panel-response, which
|
|
339
|
+
// the parent's promise API requires to settle.
|
|
340
|
+
const op = typeof cmd.op === 'string' ? cmd.op : '';
|
|
341
|
+
const { runPanelOp } = await import('./web/panel-data.js');
|
|
342
|
+
const result = await runPanelOp(app, op, cmd.params ?? {});
|
|
343
|
+
emit({ type: 'panel-response', corrId: cmd.corrId, op, ...result });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
332
346
|
case 'cancel-subagent': {
|
|
333
347
|
const mod = app.framework.getAllModules().find((m) => m.name === 'subagent') as
|
|
334
348
|
| { cancelSubagent(name: string): boolean }
|