@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +203 -10
  2. package/HEADLESS-FLEET-PLAN.md +22 -0
  3. package/README.md +22 -11
  4. package/docs/AGENT-ONBOARDING.md +20 -1
  5. package/docs/debug-context-api.md +2 -2
  6. package/docs/retrieval-traces.md +173 -0
  7. package/docs/webui-deployment.md +2 -1
  8. package/package.json +3 -3
  9. package/scripts/audit-module-optins.ts +288 -0
  10. package/scripts/warmup-session.ts +17 -3
  11. package/src/codex-subscription-adapter.ts +13 -1
  12. package/src/framework-agent-config.ts +59 -4
  13. package/src/framework-strategy.ts +33 -3
  14. package/src/headless.ts +14 -0
  15. package/src/index.ts +95 -35
  16. package/src/logging-adapter.ts +13 -2
  17. package/src/mcpl-config.ts +8 -0
  18. package/src/modules/fleet-module.ts +60 -1
  19. package/src/modules/fleet-types.ts +30 -1
  20. package/src/modules/identity-module.ts +274 -0
  21. package/src/modules/mcpl-admin-module.ts +78 -5
  22. package/src/modules/observers-module.ts +12 -0
  23. package/src/modules/retrieval-module.ts +254 -52
  24. package/src/modules/retrieval-trace-page.ts +254 -0
  25. package/src/modules/retrieval-trace.ts +904 -0
  26. package/src/modules/settings-module.ts +28 -2
  27. package/src/modules/subscription-gc-module.ts +54 -1
  28. package/src/modules/tts-relay-module.ts +33 -18
  29. package/src/modules/web-ui-module.ts +445 -894
  30. package/src/recipe.ts +137 -12
  31. package/src/retrieval-config.ts +39 -0
  32. package/src/strategies/frontdesk-strategy.ts +34 -125
  33. package/src/tui.ts +325 -54
  34. package/src/web/panel-data.ts +1187 -0
  35. package/src/web/protocol.ts +75 -10
  36. package/test/audit-module-optins.test.ts +167 -0
  37. package/test/bedrock-prompt-caching.test.ts +170 -0
  38. package/test/fleet-panel-request.test.ts +90 -0
  39. package/test/framework-strategy-defaults.test.ts +110 -0
  40. package/test/frontdesk-strategy.test.ts +25 -37
  41. package/test/headless-panel-request.test.ts +201 -0
  42. package/test/identity-and-surfaces.test.ts +157 -0
  43. package/test/mcpl-admin-module.test.ts +23 -0
  44. package/test/mock-headless-child.ts +14 -0
  45. package/test/retrieval-auth-loopback.test.ts +49 -0
  46. package/test/retrieval-config.test.ts +74 -0
  47. package/test/retrieval-module.test.ts +821 -0
  48. package/test/subscription-gc-module.test.ts +152 -0
  49. package/test/tui-format.test.ts +106 -0
  50. package/test/web-ui-context-coverage.test.ts +1 -1
  51. package/test/web-ui-module.test.ts +189 -3
  52. package/test/web-ui-observers.test.ts +8 -5
  53. package/test/web-ui-protocol.test.ts +0 -0
  54. package/web/bun.lock +345 -0
  55. package/web/src/App.tsx +159 -44
  56. package/web/src/Context.tsx +35 -8
  57. package/web/src/ContextDocument.tsx +20 -5
  58. package/web/src/Files.tsx +2 -8
  59. package/web/src/Lessons.tsx +2 -38
  60. package/web/src/Mcpl.tsx +80 -14
  61. package/web/src/Pins.tsx +5 -0
  62. package/web/src/Settings.tsx +5 -0
  63. 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
- if (!process.env.ANTHROPIC_API_KEY) {
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
- const adapter = new AnthropicAdapter({ apiKey: process.env.ANTHROPIC_API_KEY });
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
- inputTokens: terminal.usage?.input_tokens ?? 0,
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
- ...(recipe.agent.cacheTtl && { cacheTtl: recipe.agent.cacheTtl }),
27
- // Bedrock legacy Claude models reject cache_control outright
28
- // ("your request did not allow prompt caching") suppress markers.
29
- ...(recipe.agent.provider === 'bedrock' && { promptCaching: false }),
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 unless a recipe
86
- // opts out; frontdesk keeps its historical hierarchical renderer default.
87
- if (strategyType === 'autobiographical' && autobiographicalOpts.adaptiveResolution === undefined) {
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 }