@animalabs/connectome-host 0.3.8 → 0.3.10

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/src/commands.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  * /clear — Clear conversation display
15
15
  * /mcp list|add|remove|env — Manage MCPL server config
16
16
  * /budget [N] — Show/set stream token budget (e.g. /budget 1m)
17
+ * /fast [on|off|status] — Toggle Codex subscription Fast mode
17
18
  * /session — Session management (list, new, switch, rename, delete)
18
19
  * /recipe — Show current recipe info
19
20
  * /newtopic — Reset head window (auto-summarize or with user context)
@@ -37,6 +38,10 @@ interface AppContext {
37
38
  sessionManager: import('./session-manager.js').SessionManager;
38
39
  recipe: Recipe;
39
40
  branchState: BranchState;
41
+ codexAdapter?: {
42
+ isFastMode(): boolean;
43
+ setFastMode(enabled: boolean): void;
44
+ };
40
45
  switchSession(id: string): Promise<void>;
41
46
  }
42
47
 
@@ -152,6 +157,7 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
152
157
  { text: ' /mcp remove <id> Remove a server', style: 'system' },
153
158
  { text: ' /mcp env <id> K=V ... Set env vars on server', style: 'system' },
154
159
  { text: ' /budget [tokens] Show/set stream token budget', style: 'system' },
160
+ { text: ' /fast [on|off|status] Toggle Codex subscription Fast mode', style: 'system' },
155
161
  { text: ' /session Show current session', style: 'system' },
156
162
  { text: ' /session list List all sessions', style: 'system' },
157
163
  { text: ' /session new [name] Create new session', style: 'system' },
@@ -211,6 +217,9 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
211
217
  case 'budget':
212
218
  return handleBudget(framework, args[0]);
213
219
 
220
+ case 'fast':
221
+ return handleFast(app, args[0]);
222
+
214
223
  case 'session':
215
224
  return handleSession(app, args);
216
225
 
@@ -233,6 +242,42 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
233
242
  }
234
243
  }
235
244
 
245
+ function handleFast(app: AppContext, mode?: string): CommandResult {
246
+ const adapter = app.codexAdapter;
247
+ if (!adapter) {
248
+ return {
249
+ lines: [{
250
+ text: '/fast is available only when agent.provider is "openai-codex".',
251
+ style: 'system',
252
+ }],
253
+ };
254
+ }
255
+
256
+ const normalized = mode?.toLowerCase();
257
+ if (normalized === undefined || normalized === 'status') {
258
+ return {
259
+ lines: [{
260
+ text: `Codex Fast mode request is ${adapter.isFastMode() ? 'ON' : 'OFF'}.`,
261
+ style: 'system',
262
+ }],
263
+ };
264
+ }
265
+ if (normalized !== 'on' && normalized !== 'off') {
266
+ return { lines: [{ text: 'Usage: /fast [on|off|status]', style: 'system' }] };
267
+ }
268
+
269
+ const enabled = normalized === 'on';
270
+ adapter.setFastMode(enabled);
271
+ return {
272
+ lines: [{
273
+ text: enabled
274
+ ? 'Codex Fast mode requested (higher subscription credit consumption when applied).'
275
+ : 'Codex Fast mode request disabled.',
276
+ style: 'system',
277
+ }],
278
+ };
279
+ }
280
+
236
281
  // ---------------------------------------------------------------------------
237
282
  // /session subcommands
238
283
  // ---------------------------------------------------------------------------
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Recipe extensions — the loading seam for deployment-specific code.
3
+ *
4
+ * A recipe may declare an `extensions` block mapping a name to a local
5
+ * TypeScript/JavaScript module that registers custom context strategies
6
+ * and/or framework modules:
7
+ *
8
+ * "extensions": {
9
+ * "zk-strategy": {
10
+ * "kind": "strategy",
11
+ * "path": "./extensions/zk-strategy/index.ts",
12
+ * "config": { "floodWindowMs": 250 }
13
+ * }
14
+ * }
15
+ *
16
+ * The entry module must export a `register` function (named or default):
17
+ *
18
+ * export function register(api: ExtensionApi, config: Record<string, unknown>) {
19
+ * api.registerStrategy('zk', (ctx) => new ZkStrategy({ ...ctx.config }));
20
+ * api.registerModule((ctx) => new ZkFeederModule(config));
21
+ * }
22
+ *
23
+ * Design constraint: this loader is deliberately dumb. It resolves nothing,
24
+ * fetches nothing, and negotiates no versions — it imports a local path and
25
+ * calls `register`. The invariant "this path exists and is compatible with
26
+ * the host's dependency tree" is established at install time by build
27
+ * tooling (connectome-cook), not here. Extensions run in-process and share
28
+ * the host's node_modules, so `extends AutobiographicalStrategy` resolves
29
+ * against the exact @animalabs/* versions the host ships.
30
+ *
31
+ * `kind` declares the extension's primary purpose. It gates recipe
32
+ * validation (a non-built-in `agent.strategy.type` is only accepted when at
33
+ * least one `kind: "strategy"` extension is declared) and informs build
34
+ * tooling; it does NOT restrict what `register` may call — a strategy
35
+ * extension may also register a companion module.
36
+ */
37
+
38
+ import { isAbsolute } from 'node:path';
39
+ import { pathToFileURL } from 'node:url';
40
+ import type { ContextStrategy, Module } from '@animalabs/agent-framework';
41
+ import type { Recipe, RecipeExtension, RecipeStrategy } from './recipe.js';
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Factory contexts
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export interface StrategyFactoryContext {
48
+ /** The recipe's `agent.strategy` block verbatim — including keys the
49
+ * built-in schema doesn't know about, so custom strategies can define
50
+ * their own knobs without upstream schema changes. */
51
+ config: RecipeStrategy & Record<string, unknown>;
52
+ /** Resolved agent model id (recipe/env fallback chain already applied). */
53
+ model: string;
54
+ /** Resolved IANA time zone for the session. */
55
+ timeZone: string;
56
+ }
57
+
58
+ export type StrategyFactory = (ctx: StrategyFactoryContext) => ContextStrategy;
59
+
60
+ export interface ModuleFactoryContext {
61
+ timeZone: string;
62
+ /** Session store path (Chronicle data dir) — same value createFramework uses. */
63
+ storePath: string;
64
+ /** Resolved agent model id. */
65
+ model: string;
66
+ /** The declaring extension entry's `config` blob, verbatim. */
67
+ config: Record<string, unknown>;
68
+ }
69
+
70
+ export type ModuleFactory = (ctx: ModuleFactoryContext) => Module;
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Registry
74
+ // ---------------------------------------------------------------------------
75
+
76
+ export interface ExtensionApi {
77
+ /** Register a context-strategy factory under a `agent.strategy.type` name.
78
+ * Built-in names and duplicate registrations are rejected. */
79
+ registerStrategy(type: string, factory: StrategyFactory): void;
80
+ /** Register a framework-module factory. Instantiated after the built-in
81
+ * module list is assembled; if the instance has a `setFramework` method
82
+ * it is called after framework creation (same duck-typed contract the
83
+ * built-in modules use). */
84
+ registerModule(factory: ModuleFactory): void;
85
+ }
86
+
87
+ export interface RegisteredModule {
88
+ /** Name of the extension entry that registered this factory. */
89
+ extensionName: string;
90
+ factory: ModuleFactory;
91
+ /** The extension entry's `config`, passed to the factory context. */
92
+ config: Record<string, unknown>;
93
+ }
94
+
95
+ export interface ExtensionRegistry {
96
+ strategies: Map<string, StrategyFactory>;
97
+ modules: RegisteredModule[];
98
+ }
99
+
100
+ export const BUILTIN_STRATEGY_TYPES = ['autobiographical', 'passthrough', 'frontdesk'] as const;
101
+
102
+ export function isBuiltinStrategyType(type: string): boolean {
103
+ return (BUILTIN_STRATEGY_TYPES as readonly string[]).includes(type);
104
+ }
105
+
106
+ /** An empty registry — used when a recipe declares no extensions. */
107
+ export function emptyExtensionRegistry(): ExtensionRegistry {
108
+ return { strategies: new Map(), modules: [] };
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Loader
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * Import every extension declared by the recipe and collect registrations.
117
+ *
118
+ * Paths must be absolute by the time this runs — `loadRecipe` resolves
119
+ * relative extension paths against the recipe file's directory. Errors are
120
+ * wrapped with the extension name + path so a broken deployment names the
121
+ * component instead of surfacing a bare import stack.
122
+ */
123
+ export async function loadExtensions(recipe: Recipe): Promise<ExtensionRegistry> {
124
+ const registry = emptyExtensionRegistry();
125
+ for (const [name, ext] of Object.entries(recipe.extensions ?? {})) {
126
+ await loadOneExtension(name, ext, registry);
127
+ }
128
+ return registry;
129
+ }
130
+
131
+ async function loadOneExtension(
132
+ name: string,
133
+ ext: RecipeExtension,
134
+ registry: ExtensionRegistry,
135
+ ): Promise<void> {
136
+ if (!isAbsolute(ext.path)) {
137
+ throw new Error(
138
+ `extension "${name}": path "${ext.path}" is not absolute. ` +
139
+ `Relative paths are resolved by loadRecipe against the recipe file's directory; ` +
140
+ `URL-loaded recipes must use absolute extension paths.`,
141
+ );
142
+ }
143
+
144
+ let mod: Record<string, unknown>;
145
+ try {
146
+ mod = await import(pathToFileURL(ext.path).href) as Record<string, unknown>;
147
+ } catch (err) {
148
+ throw new Error(
149
+ `extension "${name}": failed to import ${ext.path}: ` +
150
+ `${err instanceof Error ? err.message : String(err)}`,
151
+ { cause: err },
152
+ );
153
+ }
154
+
155
+ const register = (mod.register ?? mod.default) as unknown;
156
+ if (typeof register !== 'function') {
157
+ throw new Error(
158
+ `extension "${name}" (${ext.path}) must export a "register" function ` +
159
+ `(named export or default export); got ${typeof register}.`,
160
+ );
161
+ }
162
+
163
+ const config = ext.config ?? {};
164
+ const api: ExtensionApi = {
165
+ registerStrategy(type, factory) {
166
+ if (typeof type !== 'string' || !type) {
167
+ throw new Error(`extension "${name}": registerStrategy requires a non-empty type name`);
168
+ }
169
+ if (isBuiltinStrategyType(type)) {
170
+ throw new Error(
171
+ `extension "${name}": strategy type "${type}" collides with a built-in strategy`,
172
+ );
173
+ }
174
+ if (registry.strategies.has(type)) {
175
+ throw new Error(
176
+ `extension "${name}": strategy type "${type}" is already registered by another extension`,
177
+ );
178
+ }
179
+ if (typeof factory !== 'function') {
180
+ throw new Error(`extension "${name}": registerStrategy("${type}") requires a factory function`);
181
+ }
182
+ registry.strategies.set(type, factory);
183
+ },
184
+ registerModule(factory) {
185
+ if (typeof factory !== 'function') {
186
+ throw new Error(`extension "${name}": registerModule requires a factory function`);
187
+ }
188
+ registry.modules.push({ extensionName: name, factory, config });
189
+ },
190
+ };
191
+
192
+ try {
193
+ await Promise.resolve((register as (api: ExtensionApi, config: Record<string, unknown>) => unknown)(api, config));
194
+ } catch (err) {
195
+ throw new Error(
196
+ `extension "${name}" (${ext.path}): register() threw: ` +
197
+ `${err instanceof Error ? err.message : String(err)}`,
198
+ { cause: err },
199
+ );
200
+ }
201
+ }
@@ -24,20 +24,28 @@ export function buildFrameworkAgentConfig(
24
24
  maxStreamTokens: recipe.agent.maxStreamTokens ?? 150000,
25
25
  contextBudgetTokens: recipe.agent.contextBudgetTokens,
26
26
  ...(recipe.agent.cacheTtl && { cacheTtl: recipe.agent.cacheTtl }),
27
- ...(recipe.agent.provider === 'openai-responses' && {
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 }),
30
+ // Prefill scaffold (anthropic-xml formatter), e.g. chapterx CLI-sim's
31
+ // '<cmd>cat untitled.txt</cmd>' — part of migrating prefill-era bots.
32
+ ...(recipe.agent.prefillUserMessage && { prefillUserMessage: recipe.agent.prefillUserMessage }),
33
+ ...((recipe.agent.provider === 'openai-responses' || recipe.agent.provider === 'openai-codex') && {
28
34
  providerParams: {
29
35
  reasoning: {
30
36
  effort: recipe.agent.responses?.reasoningEffort ?? 'high',
31
37
  context: recipe.agent.responses?.reasoningContext ?? 'all_turns',
32
38
  },
33
- ...(recipe.agent.responses?.serviceTier ? {
34
- service_tier: recipe.agent.responses.serviceTier,
35
- } : {}),
36
- ...(recipe.agent.responses?.compactThreshold ? {
37
- context_management: [{
38
- type: 'compaction',
39
- compact_threshold: recipe.agent.responses.compactThreshold,
40
- }],
39
+ ...(recipe.agent.provider === 'openai-responses' ? {
40
+ ...(recipe.agent.responses?.serviceTier ? {
41
+ service_tier: recipe.agent.responses.serviceTier,
42
+ } : {}),
43
+ ...(recipe.agent.responses?.compactThreshold ? {
44
+ context_management: [{
45
+ type: 'compaction',
46
+ compact_threshold: recipe.agent.responses.compactThreshold,
47
+ }],
48
+ } : {}),
41
49
  } : {}),
42
50
  },
43
51
  }),
@@ -5,6 +5,7 @@ import {
5
5
  } from '@animalabs/agent-framework';
6
6
  import type { Recipe, RecipeStrategy } from './recipe.js';
7
7
  import { FrontdeskStrategy } from './strategies/frontdesk-strategy.js';
8
+ import { isBuiltinStrategyType, type ExtensionRegistry } from './extensions.js';
8
9
 
9
10
  const PASSTHROUGH_KEYS: ReadonlyArray<keyof RecipeStrategy> = [
10
11
  'enforceBudget',
@@ -39,9 +40,29 @@ export function buildFrameworkStrategy(
39
40
  recipe: Recipe,
40
41
  model: string,
41
42
  timeZone: string,
43
+ extensions?: ExtensionRegistry,
42
44
  ): ContextStrategy {
43
45
  const strategyConfig = recipe.agent.strategy;
44
46
  const strategyType = strategyConfig?.type ?? 'autobiographical';
47
+
48
+ // Non-built-in types resolve through the extension registry. Validation
49
+ // already required a strategy-kind extension to be declared; this catches
50
+ // the declared-but-didn't-register case with a precise error.
51
+ if (!isBuiltinStrategyType(strategyType)) {
52
+ const factory = extensions?.strategies.get(strategyType);
53
+ if (!factory) {
54
+ const known = extensions ? Array.from(extensions.strategies.keys()) : [];
55
+ throw new Error(
56
+ `strategy type "${strategyType}" is not built-in and no loaded extension registered it. ` +
57
+ `Registered custom types: ${known.length ? known.join(', ') : '(none)'}.`,
58
+ );
59
+ }
60
+ return factory({
61
+ config: (strategyConfig ?? {}) as RecipeStrategy & Record<string, unknown>,
62
+ model,
63
+ timeZone,
64
+ });
65
+ }
45
66
  const autobiographicalOpts: Record<string, unknown> = {
46
67
  headWindowTokens: strategyConfig?.headWindowTokens ?? 4000,
47
68
  recentWindowTokens: strategyConfig?.recentWindowTokens ?? 30000,
package/src/index.ts CHANGED
@@ -16,12 +16,17 @@
16
16
  */
17
17
 
18
18
  import {
19
+ AnthropicXmlFormatter,
20
+ BedrockAdapter,
19
21
  Membrane,
20
22
  NativeFormatter,
21
23
  OpenAIResponsesAPIAdapter,
22
24
  OpenAIResponsesFormatter,
25
+ OpenRouterAdapter,
23
26
  } from '@animalabs/membrane';
24
27
  import { LoggingAnthropicAdapter } from './logging-adapter.js';
28
+ import { LoggingBedrockAdapter } from './logging-bedrock-adapter.js';
29
+ import { CodexSubscriptionAdapter } from './codex-subscription-adapter.js';
25
30
  import { CallLedger } from './call-ledger.js';
26
31
  import { SettingsModule } from './modules/settings-module.js';
27
32
  import { AgentFramework, WorkspaceModule, resolveTimeZone, type Module, type MountConfig } from '@animalabs/agent-framework';
@@ -57,6 +62,7 @@ import {
57
62
  import { createBranchState, resetBranchState, handleExport, type BranchState } from './commands.js';
58
63
  import { buildFrameworkAgentConfig } from './framework-agent-config.js';
59
64
  import { buildFrameworkStrategy } from './framework-strategy.js';
65
+ import { loadExtensions } from './extensions.js';
60
66
 
61
67
  export type { AppContext };
62
68
 
@@ -69,6 +75,8 @@ const config = {
69
75
  // precedence over the API key so requests never carry both auth schemes.
70
76
  authToken: process.env.ANTHROPIC_AUTH_TOKEN,
71
77
  openaiApiKey: process.env.OPENAI_API_KEY,
78
+ openrouterApiKey: process.env.OPENROUTER_API_KEY,
79
+ codexBinary: process.env.CODEX_BINARY,
72
80
  model: process.env.MODEL,
73
81
  dataDir: process.env.DATA_DIR || './data',
74
82
  };
@@ -91,6 +99,7 @@ interface AppContext {
91
99
  agentName: string;
92
100
  branchState: BranchState;
93
101
  userMessageCount: number;
102
+ codexAdapter?: CodexSubscriptionAdapter;
94
103
 
95
104
  /** Stop current framework, switch to a different session, start new framework. */
96
105
  switchSession(id: string): Promise<void>;
@@ -143,10 +152,16 @@ async function createFramework(
143
152
  settingsModule: SettingsModule,
144
153
  callLedger: CallLedger | null,
145
154
  ): Promise<AgentFramework> {
146
- const model = config.model || recipe.agent.model || 'claude-opus-4-6';
155
+ const model = config.model || recipe.agent.model ||
156
+ (recipe.agent.provider === 'openai-codex' ? 'gpt-5.4' : 'claude-opus-4-6');
147
157
  const modules = recipe.modules ?? {};
148
158
  const timeZone = resolveTimeZone(recipe.agent.timezone);
149
159
 
160
+ // Load recipe extensions first — custom strategies must be registered
161
+ // before buildFrameworkStrategy runs, and custom modules join the module
162
+ // list below. The loader is a dumb local import; see src/extensions.ts.
163
+ const extensionRegistry = await loadExtensions(recipe);
164
+
150
165
  // -- Build module list --
151
166
  // SettingsModule is constructed in main() (before the adapter, so the
152
167
  // adapter can read its state for cross-cutting concerns like reasoning).
@@ -342,6 +357,16 @@ async function createFramework(
342
357
  moduleInstances.push(new ObserversModule({ path: observersPath }));
343
358
  }
344
359
 
360
+ // Extension-registered modules. Instantiated last so built-in modules keep
361
+ // their historical positions; each factory gets the minimal runtime context
362
+ // plus its declaring extension's config blob.
363
+ const extensionModules: Module[] = [];
364
+ for (const entry of extensionRegistry.modules) {
365
+ const instance = entry.factory({ timeZone, storePath, model, config: entry.config });
366
+ extensionModules.push(instance);
367
+ moduleInstances.push(instance);
368
+ }
369
+
345
370
  // -- Build MCP server list --
346
371
  //
347
372
  // Recipes are opt-in: a file entry from mcpl-servers.json is loaded only
@@ -401,14 +426,14 @@ async function createFramework(
401
426
  // No server augmentation needed — gate is wired via FrameworkConfig.gate
402
427
 
403
428
  // -- Build strategy --
404
- const strategy = buildFrameworkStrategy(recipe, model, timeZone);
429
+ const strategy = buildFrameworkStrategy(recipe, model, timeZone, extensionRegistry);
405
430
  const agentConfig = buildFrameworkAgentConfig(recipe, agentName, model, strategy);
406
431
 
407
432
  // -- Create framework --
408
433
  const framework = await AgentFramework.create({
409
434
  storePath,
410
435
  membrane,
411
- agents: [agentConfig],
436
+ agents: [agentConfig],
412
437
  modules: moduleInstances,
413
438
  mcplServers: finalServers,
414
439
  gate: gateOptions,
@@ -467,6 +492,13 @@ async function createFramework(
467
492
  channelModeModule.setFramework(framework);
468
493
  }
469
494
 
495
+ // Extension modules get the same duck-typed post-creation hook the
496
+ // built-ins use: if the instance exposes setFramework, call it.
497
+ for (const instance of extensionModules) {
498
+ const hooked = instance as unknown as { setFramework?: (f: AgentFramework) => void };
499
+ hooked.setFramework?.(framework);
500
+ }
501
+
470
502
  if (mcplAdminModule) {
471
503
  mcplAdminModule.setFramework(framework);
472
504
  }
@@ -733,10 +765,18 @@ async function main() {
733
765
  const recipe = await resolveRecipe();
734
766
  const provider = recipe.agent.provider ?? 'anthropic';
735
767
 
768
+ if (provider === 'openrouter' && !config.openrouterApiKey) {
769
+ console.error('Missing OPENROUTER_API_KEY for recipe provider "openrouter".');
770
+ process.exit(1);
771
+ }
736
772
  if (provider === 'openai-responses' && !config.openaiApiKey) {
737
773
  console.error('Missing OPENAI_API_KEY for recipe provider "openai-responses".');
738
774
  process.exit(1);
739
775
  }
776
+ if (provider === 'bedrock' && !(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY)) {
777
+ console.error('Missing AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY for recipe provider "bedrock".');
778
+ process.exit(1);
779
+ }
740
780
  if (provider === 'anthropic' && !config.apiKey && !config.authToken) {
741
781
  console.error('Missing ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN). Set one in .env or environment.');
742
782
  process.exit(1);
@@ -761,22 +801,54 @@ async function main() {
761
801
  defaultTtl: recipe.agent.cacheTtl ?? '5m',
762
802
  })
763
803
  : null;
764
- // OAuth (subscription) auth wins over API-key auth when both are present.
765
- // Subscription tokens (sk-ant-oat…) additionally require the oauth beta
766
- // header on every request.
804
+ // The Codex subscription adapter owns ChatGPT login/refresh independently
805
+ // of the API-key transports below.
806
+ const codexAdapter = provider === 'openai-codex'
807
+ ? new CodexSubscriptionAdapter({
808
+ codexBinary: config.codexBinary,
809
+ fastMode: recipe.agent.codex?.fastMode ?? false,
810
+ })
811
+ : undefined;
812
+ const openrouterAdapter = provider === 'openrouter'
813
+ ? new OpenRouterAdapter({
814
+ apiKey: config.openrouterApiKey!,
815
+ xTitle: recipe.agent.name ?? recipe.name,
816
+ })
817
+ : undefined;
818
+ // Bedrock: legacy Claude models (3.5 Sonnet 0620/1022, Opus 3) that have
819
+ // left the Anthropic API but survive on AWS. The adapter reads AWS_* env
820
+ // vars (AWS_REGION defaults us-west-2) and maps standard Claude model IDs
821
+ // to Bedrock IDs (explicit map + `anthropic.<id>-v1:0` fallback). Uses the
822
+ // Anthropic-native message shape, so NativeFormatter applies unchanged.
823
+ // No CallLedger: prompt caching is rejected outright by legacy Bedrock
824
+ // models (tested 2026-07-21). Wrapped for llm-calls.jsonl visibility.
825
+ const bedrockAdapter = provider === 'bedrock'
826
+ ? new LoggingBedrockAdapter({}, llmLogPath)
827
+ : undefined;
767
828
  const adapter = provider === 'openai-responses'
768
829
  ? new OpenAIResponsesAPIAdapter({
769
830
  apiKey: config.openaiApiKey!,
770
831
  baseURL: process.env.OPENAI_BASE_URL || undefined,
771
832
  })
772
- : new LoggingAnthropicAdapter(
833
+ : bedrockAdapter ?? openrouterAdapter ?? codexAdapter ?? new LoggingAnthropicAdapter(
773
834
  {
835
+ // Anthropic OAuth wins over API-key auth when both are present.
836
+ // Subscription tokens additionally require this beta header.
837
+ // Recipe-declared betas (agent.anthropicBetas, e.g. context-1m)
838
+ // are merged into the same anthropic-beta header either way.
774
839
  ...(config.authToken
775
840
  ? {
776
841
  authToken: config.authToken,
777
- defaultHeaders: { 'anthropic-beta': 'oauth-2025-04-20' },
842
+ defaultHeaders: {
843
+ 'anthropic-beta': ['oauth-2025-04-20', ...(recipe.agent.anthropicBetas ?? [])].join(','),
844
+ },
778
845
  }
779
- : { apiKey: config.apiKey! }),
846
+ : {
847
+ apiKey: config.apiKey!,
848
+ ...(recipe.agent.anthropicBetas?.length
849
+ ? { defaultHeaders: { 'anthropic-beta': recipe.agent.anthropicBetas.join(',') } }
850
+ : {}),
851
+ }),
780
852
  baseURL: process.env.ANTHROPIC_BASE_URL || undefined,
781
853
  },
782
854
  llmLogPath,
@@ -815,9 +887,15 @@ async function main() {
815
887
  const agentName = resolved.name;
816
888
 
817
889
  const membrane = new Membrane(adapter, {
818
- formatter: provider === 'openai-responses'
890
+ formatter: provider === 'openai-responses' || provider === 'openai-codex'
819
891
  ? new OpenAIResponsesFormatter()
820
- : new NativeFormatter(),
892
+ : recipe.agent.formatter === 'anthropic-xml'
893
+ ? new AnthropicXmlFormatter()
894
+ : new NativeFormatter(),
895
+ // Bedrock legacy Claude models 400 on any cache_control block
896
+ // ("your request did not allow prompt caching") — suppress the
897
+ // historical promptCaching=true default on that transport.
898
+ ...(provider === 'bedrock' ? { defaultPromptCaching: false } : {}),
821
899
  // Anchor the assistant role for internal callers that don't set
822
900
  // request.assistantParticipant themselves (autobio compression,
823
901
  // executeMerge). Mismatch here flips stored assistant turns to
@@ -837,6 +915,7 @@ async function main() {
837
915
  agentName,
838
916
  branchState: createBranchState(),
839
917
  userMessageCount: 0,
918
+ codexAdapter,
840
919
 
841
920
  async switchSession(id: string) {
842
921
  handleExport(this);
@@ -884,14 +963,18 @@ async function main() {
884
963
  setupMcplStderrLog(app, storePath);
885
964
  getWebUiModule(framework)?.setApp(app);
886
965
 
887
- if (headless) {
888
- const { runHeadless } = await import('./headless.js');
889
- await runHeadless(app, process.argv.slice(2));
890
- } else if (noTui) {
891
- await runPiped(app);
892
- } else {
893
- const { runTui } = await import('./tui.js');
894
- await runTui(app);
966
+ try {
967
+ if (headless) {
968
+ const { runHeadless } = await import('./headless.js');
969
+ await runHeadless(app, process.argv.slice(2));
970
+ } else if (noTui) {
971
+ await runPiped(app);
972
+ } else {
973
+ const { runTui } = await import('./tui.js');
974
+ await runTui(app);
975
+ }
976
+ } finally {
977
+ codexAdapter?.dispose();
895
978
  }
896
979
  }
897
980