@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.
Files changed (51) hide show
  1. package/.env.example +6 -0
  2. package/.github/workflows/ci.yml +52 -0
  3. package/.github/workflows/publish.yml +2 -2
  4. package/README.md +5 -0
  5. package/bun.lock +14 -6
  6. package/docs/AGENT-ONBOARDING.md +6 -1
  7. package/package.json +6 -4
  8. package/scripts/import-codex-rollout.ts +288 -0
  9. package/src/call-ledger.ts +371 -0
  10. package/src/call-pricing.ts +119 -0
  11. package/src/framework-agent-config.ts +51 -0
  12. package/src/framework-strategy.ts +70 -0
  13. package/src/index.ts +130 -105
  14. package/src/logging-adapter.ts +105 -12
  15. package/src/mcpl-config.ts +1 -0
  16. package/src/modules/channel-mode-module.ts +14 -16
  17. package/src/modules/fleet-module.ts +17 -7
  18. package/src/modules/mcpl-admin-module.ts +6 -15
  19. package/src/modules/settings-module.ts +83 -59
  20. package/src/modules/subscription-gc-module.ts +17 -20
  21. package/src/modules/time-module.ts +15 -9
  22. package/src/modules/web-ui-module.ts +88 -10
  23. package/src/modules/web-ui-observers.ts +35 -9
  24. package/src/recipe.ts +133 -6
  25. package/src/state/agent-tree-reducer.ts +17 -3
  26. package/src/strategies/frontdesk-strategy.ts +15 -9
  27. package/src/web/protocol.ts +89 -0
  28. package/test/agent-tree-reducer.test.ts +35 -3
  29. package/test/autobio-progress-snapshot.test.ts +27 -12
  30. package/test/call-ledger.test.ts +91 -0
  31. package/test/call-pricing.test.ts +69 -0
  32. package/test/framework-fkm-composition.test.ts +160 -0
  33. package/test/frontdesk-strategy.test.ts +10 -0
  34. package/test/import-codex-rollout.test.ts +36 -0
  35. package/test/logging-adapter-refusal.test.ts +57 -0
  36. package/test/logging-adapter.test.ts +58 -1
  37. package/test/recipe-cache-ttl.test.ts +7 -3
  38. package/test/recipe-compression-fallback.test.ts +36 -0
  39. package/test/recipe-primary-summary-fallback.test.ts +40 -0
  40. package/test/recipe-provider.test.ts +36 -0
  41. package/test/recipe-think-policy.test.ts +39 -0
  42. package/test/recipe-timezone.test.ts +20 -0
  43. package/test/subscription-gc-module.test.ts +7 -5
  44. package/test/time-module.test.ts +13 -0
  45. package/test/web-ui-observers.test.ts +70 -2
  46. package/web/package-lock.json +425 -302
  47. package/web/src/App.tsx +9 -0
  48. package/web/src/ObserverGate.tsx +21 -11
  49. package/web/src/Usage.tsx +116 -2
  50. package/web/src/wire.ts +5 -2
  51. package/web/bun.lock +0 -357
package/src/index.ts CHANGED
@@ -15,18 +15,23 @@
15
15
  * DATA_DIR - Data directory for sessions (default: ./data)
16
16
  */
17
17
 
18
- import { Membrane, NativeFormatter } from '@animalabs/membrane';
18
+ import {
19
+ Membrane,
20
+ NativeFormatter,
21
+ OpenAIResponsesAPIAdapter,
22
+ OpenAIResponsesFormatter,
23
+ } from '@animalabs/membrane';
19
24
  import { LoggingAnthropicAdapter } from './logging-adapter.js';
25
+ import { CallLedger } from './call-ledger.js';
20
26
  import { SettingsModule } from './modules/settings-module.js';
21
- import { AgentFramework, AutobiographicalStrategy, PassthroughStrategy, WorkspaceModule, type Module, type MountConfig } from '@animalabs/agent-framework';
27
+ import { AgentFramework, WorkspaceModule, resolveTimeZone, type Module, type MountConfig } from '@animalabs/agent-framework';
22
28
  import { resolve, join, basename } from 'node:path';
23
29
  import { appendFile, mkdir, stat, rename } from 'node:fs/promises';
24
30
  import { readFileSync, existsSync } from 'node:fs';
25
- import { FrontdeskStrategy } from './strategies/frontdesk-strategy.js';
26
31
  import { SubagentModule } from './modules/subagent-module.js';
27
32
  import { LessonsModule } from './modules/lessons-module.js';
28
33
  import { RetrievalModule } from './modules/retrieval-module.js';
29
- import type { RecipeWorkspaceMount, RecipeStrategy } from './recipe.js';
34
+ import type { RecipeWorkspaceMount } from './recipe.js';
30
35
  import { TuiModule } from './modules/tui-module.js';
31
36
  import { TimeModule } from './modules/time-module.js';
32
37
  import { FleetModule, type FleetModuleConfig } from './modules/fleet-module.js';
@@ -50,6 +55,8 @@ import {
50
55
  parseRecipeArg,
51
56
  } from './recipe.js';
52
57
  import { createBranchState, resetBranchState, handleExport, type BranchState } from './commands.js';
58
+ import { buildFrameworkAgentConfig } from './framework-agent-config.js';
59
+ import { buildFrameworkStrategy } from './framework-strategy.js';
53
60
 
54
61
  export type { AppContext };
55
62
 
@@ -61,15 +68,11 @@ const config = {
61
68
  // OAuth/Bearer token (e.g. a Claude subscription token). When set, it takes
62
69
  // precedence over the API key so requests never carry both auth schemes.
63
70
  authToken: process.env.ANTHROPIC_AUTH_TOKEN,
71
+ openaiApiKey: process.env.OPENAI_API_KEY,
64
72
  model: process.env.MODEL,
65
73
  dataDir: process.env.DATA_DIR || './data',
66
74
  };
67
75
 
68
- if (!config.apiKey && !config.authToken) {
69
- console.error('Missing ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN). Set one in .env or environment.');
70
- process.exit(1);
71
- }
72
-
73
76
  // ---------------------------------------------------------------------------
74
77
  // AppContext — mutable container for session switching
75
78
  // ---------------------------------------------------------------------------
@@ -138,14 +141,16 @@ async function createFramework(
138
141
  recipe: Recipe,
139
142
  agentName: string,
140
143
  settingsModule: SettingsModule,
144
+ callLedger: CallLedger | null,
141
145
  ): Promise<AgentFramework> {
142
146
  const model = config.model || recipe.agent.model || 'claude-opus-4-6';
143
147
  const modules = recipe.modules ?? {};
148
+ const timeZone = resolveTimeZone(recipe.agent.timezone);
144
149
 
145
150
  // -- Build module list --
146
151
  // SettingsModule is constructed in main() (before the adapter, so the
147
152
  // adapter can read its state for cross-cutting concerns like reasoning).
148
- const moduleInstances: Module[] = [new TuiModule(), new TimeModule(), settingsModule];
153
+ const moduleInstances: Module[] = [new TuiModule(), new TimeModule(timeZone), settingsModule];
149
154
 
150
155
  // Subagents
151
156
  let subagentModule: SubagentModule | null = null;
@@ -169,10 +174,11 @@ async function createFramework(
169
174
 
170
175
  // Fleet (cross-process child orchestration). Opt-in via recipe.
171
176
  if (modules.fleet === true) {
172
- moduleInstances.push(new FleetModule());
177
+ moduleInstances.push(new FleetModule({ timeZone }));
173
178
  } else if (modules.fleet && typeof modules.fleet === 'object') {
174
179
  const fleetCfg = modules.fleet;
175
180
  const fleetModuleConfig: FleetModuleConfig = {};
181
+ fleetModuleConfig.timeZone = timeZone;
176
182
  if (fleetCfg.children) {
177
183
  fleetModuleConfig.autoStart = fleetCfg.children.map((c) => {
178
184
  const entry: NonNullable<FleetModuleConfig['autoStart']>[number] = {
@@ -310,7 +316,7 @@ async function createFramework(
310
316
  // ability to spawn arbitrary commands via mcpl_deploy; see recipe.ts).
311
317
  let mcplAdminModule: McplAdminModule | null = null;
312
318
  if (modules.mcplAdmin === true) {
313
- mcplAdminModule = new McplAdminModule();
319
+ mcplAdminModule = new McplAdminModule({ timeZone });
314
320
  moduleInstances.push(mcplAdminModule);
315
321
  }
316
322
 
@@ -330,6 +336,7 @@ async function createFramework(
330
336
  basicAuth: webuiConfig.basicAuth,
331
337
  allowedOrigins: webuiConfig.allowedOrigins,
332
338
  observersPath,
339
+ ...(callLedger ? { callLedger } : {}),
333
340
  });
334
341
  moduleInstances.push(webUiModule);
335
342
  moduleInstances.push(new ObserversModule({ path: observersPath }));
@@ -384,98 +391,70 @@ async function createFramework(
384
391
  // Apply the agent overlay (mcpl-servers.agent.json): servers the agent
385
392
  // deployed for itself load unconditionally (no recipe opt-in), and
386
393
  // tombstones suppress recipe/file servers the agent unloaded.
387
- const finalServers = applyAgentOverlay(allServers, DEFAULT_AGENT_OVERLAY_PATH);
394
+ const finalServers = applyAgentOverlay(allServers, DEFAULT_AGENT_OVERLAY_PATH).map((server) => ({
395
+ ...server,
396
+ // Stdio MCPL children inherit a single agent-facing wall clock. Protocol
397
+ // timestamps remain UTC; only their rendered text uses this setting.
398
+ env: { ...(server.env ?? {}), AGENT_TIMEZONE: timeZone },
399
+ }));
388
400
 
389
401
  // No server augmentation needed — gate is wired via FrameworkConfig.gate
390
402
 
391
403
  // -- Build strategy --
392
- //
393
- // Build the options object with typed property access — no
394
- // `Record<string, unknown>` cast on `strategyConfig`. Every field we
395
- // forward is declared on `RecipeStrategy` (see recipe.ts); a typo in a
396
- // recipe (e.g. `l1BudgetTokes`) now fails at recipe validation rather
397
- // than silently being a no-op at strategy construction. AutobiographicalStrategy
398
- // and FrontdeskStrategy share this option bag today; if strategy-specific
399
- // fields are ever added, this should split into per-strategy types.
400
- const strategyConfig = recipe.agent.strategy;
401
- const strategyType = strategyConfig?.type ?? 'autobiographical';
402
- const autobiographicalOpts: Record<string, unknown> = {
403
- headWindowTokens: strategyConfig?.headWindowTokens ?? 4000,
404
- recentWindowTokens: strategyConfig?.recentWindowTokens ?? 30000,
405
- compressionModel: strategyConfig?.compressionModel ?? model,
406
- autoTickOnNewMessage: true,
407
- maxMessageTokens: strategyConfig?.maxMessageTokens ?? 10000,
408
- };
409
- // Forward optional tuning fields when set. The key list is typed
410
- // against `RecipeStrategy`, so an unknown field name is a compile
411
- // error here rather than a silent no-op at runtime.
412
- const passthroughKeys: ReadonlyArray<keyof RecipeStrategy> = [
413
- 'enforceBudget',
414
- 'maxSpeculativeL1s',
415
- 'positionedRecallPairs',
416
- 'recallHeaderTemplate',
417
- 'targetChunkTokens',
418
- 'mergeThreshold',
419
- 'summaryTargetTokens',
420
- 'l1BudgetTokens',
421
- 'l2BudgetTokens',
422
- 'l3BudgetTokens',
423
- 'toolResultMaxLastN',
424
- 'toolUseInputMaxTokens',
425
- 'adaptiveResolution',
426
- 'kvStableReachTokens',
427
- 'kvStableQualityGapRatio',
428
- 'compressionSlackRatio',
429
- 'overBudgetGraceRatio',
430
- 'foldingStrategy',
431
- 'speculativeProduction',
432
- 'l1HoldbackChunks',
433
- 'summaryParticipant',
434
- 'summarySystemPrompt',
435
- 'summaryUserPrompt',
436
- 'summaryContextLabel',
437
- ];
438
- for (const key of passthroughKeys) {
439
- const v = strategyConfig?.[key];
440
- if (v !== undefined) autobiographicalOpts[key] = v;
441
- }
442
- // Adaptive resolution (document-based gradual compression) is the intended
443
- // default for autobiographical agents. Frontdesk keeps the hierarchical
444
- // renderer (its salience-biased L1 selection); it can still opt in via the
445
- // recipe. A recipe may set `adaptiveResolution: false` to opt back out.
446
- if (strategyType === 'autobiographical' && autobiographicalOpts.adaptiveResolution === undefined) {
447
- autobiographicalOpts.adaptiveResolution = true;
448
- }
449
- const strategy = strategyType === 'passthrough'
450
- ? new PassthroughStrategy()
451
- : strategyType === 'frontdesk'
452
- ? new FrontdeskStrategy(autobiographicalOpts)
453
- : new AutobiographicalStrategy(autobiographicalOpts);
404
+ const strategy = buildFrameworkStrategy(recipe, model, timeZone);
405
+ const agentConfig = buildFrameworkAgentConfig(recipe, agentName, model, strategy);
454
406
 
455
407
  // -- Create framework --
456
408
  const framework = await AgentFramework.create({
457
409
  storePath,
458
410
  membrane,
459
- agents: [
460
- {
461
- name: agentName,
462
- model,
463
- systemPrompt: recipe.agent.systemPrompt,
464
- maxTokens: recipe.agent.maxTokens ?? 16384,
465
- maxStreamTokens: recipe.agent.maxStreamTokens ?? 150000,
466
- contextBudgetTokens: recipe.agent.contextBudgetTokens,
467
- ...(recipe.agent.cacheTtl && { cacheTtl: recipe.agent.cacheTtl }),
468
- strategy,
469
- ...(recipe.agent.thinking && { thinking: recipe.agent.thinking }),
470
- ...(recipe.agent.refusalHandling && { refusalHandling: recipe.agent.refusalHandling }),
471
- },
472
- ],
411
+ agents: [agentConfig],
473
412
  modules: moduleInstances,
474
413
  mcplServers: finalServers,
475
414
  gate: gateOptions,
415
+ timeZone,
476
416
  });
477
417
 
478
418
  // Wire post-creation hooks
419
+ // Compression-quarantine klaxon → the framework's ops-alert channel
420
+ // (failures.log + ops:alert trace + CONNECTOME_OPS_WEBHOOK). The strategy
421
+ // re-fires this every alarm interval for as long as ANY chunk is
422
+ // quarantined: quarantined spans stay raw, the fold floor creeps, and the
423
+ // picker eventually cannot fit the window — a guaranteed future outage
424
+ // that must never be a silent state.
425
+ // Duck-typed on both sides so version skew in either dep degrades to a
426
+ // no-op (the strategy's own stderr klaxon still fires) instead of a crash.
427
+ {
428
+ const alarmCapable = strategy as unknown as {
429
+ setQuarantineAlarmHandler?: (fn: (status: { count: number; keys: string[] }) => void) => void;
430
+ };
431
+ const notify = (framework as unknown as {
432
+ notifyOps?: (kind: string, agent: string, message: string, data?: Record<string, unknown>) => void;
433
+ }).notifyOps?.bind(framework);
434
+ if (alarmCapable.setQuarantineAlarmHandler && notify) {
435
+ alarmCapable.setQuarantineAlarmHandler((status) => {
436
+ if (status.count === 0) {
437
+ // All-clear travels the same channel the alarm did, under a
438
+ // DISTINCT kind: the alarm kind's 15-min ops cooldown must never
439
+ // swallow the stand-down (silence after an alarm is ambiguous).
440
+ notify(
441
+ 'compression-quarantine-clear',
442
+ agentName,
443
+ 'compression quarantine EMPTY — all debt paid; alarm stands down.',
444
+ { count: 0 },
445
+ );
446
+ return;
447
+ }
448
+ notify(
449
+ 'compression-quarantine',
450
+ agentName,
451
+ `${status.count} chunk(s) in compression quarantine — spans stay raw and WILL eventually exhaust the context budget. Operator action required (inspect refusing content; branch, pin, or clear).`,
452
+ { count: status.count, keys: status.keys },
453
+ );
454
+ });
455
+ }
456
+ }
457
+
479
458
  if (subagentModule) {
480
459
  subagentModule.setFramework(framework);
481
460
  }
@@ -752,6 +731,16 @@ function countLines(path: string): number {
752
731
 
753
732
  async function main() {
754
733
  const recipe = await resolveRecipe();
734
+ const provider = recipe.agent.provider ?? 'anthropic';
735
+
736
+ if (provider === 'openai-responses' && !config.openaiApiKey) {
737
+ console.error('Missing OPENAI_API_KEY for recipe provider "openai-responses".');
738
+ process.exit(1);
739
+ }
740
+ if (provider === 'anthropic' && !config.apiKey && !config.authToken) {
741
+ console.error('Missing ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN). Set one in .env or environment.');
742
+ process.exit(1);
743
+ }
755
744
 
756
745
  // SettingsModule constructed early so the adapter can read its state for
757
746
  // cross-cutting concerns (currently: reasoning). It's wired into the
@@ -766,22 +755,34 @@ async function main() {
766
755
  config.dataDir,
767
756
  `llm-calls.${new Date().toISOString().replace(/[:.]/g, '-')}.jsonl`,
768
757
  );
758
+ const callLedger = provider === 'anthropic'
759
+ ? new CallLedger({
760
+ dataDir: config.dataDir,
761
+ defaultTtl: recipe.agent.cacheTtl ?? '5m',
762
+ })
763
+ : null;
769
764
  // OAuth (subscription) auth wins over API-key auth when both are present.
770
765
  // Subscription tokens (sk-ant-oat…) additionally require the oauth beta
771
766
  // header on every request.
772
- const adapter = new LoggingAnthropicAdapter(
773
- {
774
- ...(config.authToken
775
- ? {
776
- authToken: config.authToken,
777
- defaultHeaders: { 'anthropic-beta': 'oauth-2025-04-20' },
778
- }
779
- : { apiKey: config.apiKey! }),
780
- baseURL: process.env.ANTHROPIC_BASE_URL || undefined,
781
- },
782
- llmLogPath,
783
- () => settingsModule.getReasoning(),
784
- );
767
+ const adapter = provider === 'openai-responses'
768
+ ? new OpenAIResponsesAPIAdapter({
769
+ apiKey: config.openaiApiKey!,
770
+ baseURL: process.env.OPENAI_BASE_URL || undefined,
771
+ })
772
+ : new LoggingAnthropicAdapter(
773
+ {
774
+ ...(config.authToken
775
+ ? {
776
+ authToken: config.authToken,
777
+ defaultHeaders: { 'anthropic-beta': 'oauth-2025-04-20' },
778
+ }
779
+ : { apiKey: config.apiKey! }),
780
+ baseURL: process.env.ANTHROPIC_BASE_URL || undefined,
781
+ },
782
+ llmLogPath,
783
+ () => settingsModule.getReasoning(),
784
+ (record) => callLedger!.record(record),
785
+ );
785
786
 
786
787
  // Session management — resolved before Membrane construction so the
787
788
  // active session's import-source sidecar can contribute to agent-name
@@ -814,7 +815,9 @@ async function main() {
814
815
  const agentName = resolved.name;
815
816
 
816
817
  const membrane = new Membrane(adapter, {
817
- formatter: new NativeFormatter(),
818
+ formatter: provider === 'openai-responses'
819
+ ? new OpenAIResponsesFormatter()
820
+ : new NativeFormatter(),
818
821
  // Anchor the assistant role for internal callers that don't set
819
822
  // request.assistantParticipant themselves (autobio compression,
820
823
  // executeMerge). Mismatch here flips stored assistant turns to
@@ -823,7 +826,7 @@ async function main() {
823
826
  });
824
827
 
825
828
  const storePath = sessionManager.getStorePath(activeSession.id);
826
- const framework = await createFramework(membrane, storePath, recipe, agentName, settingsModule);
829
+ const framework = await createFramework(membrane, storePath, recipe, agentName, settingsModule, callLedger);
827
830
 
828
831
  // Build app context
829
832
  const app: AppContext = {
@@ -844,7 +847,7 @@ async function main() {
844
847
  // re-resolution would matter only if recipe.agent.name is absent
845
848
  // AND the user switches between imports that used different
846
849
  // --agent values; not the canonical flow.
847
- this.framework = await createFramework(membrane, newStorePath, recipe, this.agentName, settingsModule);
850
+ this.framework = await createFramework(membrane, newStorePath, recipe, this.agentName, settingsModule, callLedger);
848
851
  this.framework.start();
849
852
  this.userMessageCount = 0;
850
853
  resetBranchState(this.branchState);
@@ -854,6 +857,28 @@ async function main() {
854
857
  },
855
858
  };
856
859
 
860
+ // Off-path refusal dragnet → ops alerts (observability M3): refusals on
861
+ // non-streamed calls (compression/summarizer drains, maintenance) never
862
+ // reach the framework's own noteRefusal — the 2026-07-15 mythos cascade
863
+ // started exactly there, silently. Surface them through the same
864
+ // opsAlert pipeline (failures.log + ops:alert trace + throttled webhook).
865
+ // Reads app.framework (not the closure) so session switches stay wired;
866
+ // feature-detects notifyOpsAlert for older framework versions.
867
+ if (adapter instanceof LoggingAnthropicAdapter) {
868
+ adapter.onRefusal = (info) => {
869
+ const fw = app.framework as unknown as {
870
+ notifyOpsAlert?: (kind: string, agent: string, msg: string, data?: Record<string, unknown>) => void;
871
+ };
872
+ fw.notifyOpsAlert?.(
873
+ 'refusal-offpath',
874
+ app.agentName,
875
+ `off-path refusal (category=${info.category ?? 'unknown'}) on a ${info.messages}-message ` +
876
+ `complete() call (~${Math.round(info.inputTokens / 1000)}k tok) — likely compression/summarizer`,
877
+ { ...info },
878
+ );
879
+ };
880
+ }
881
+
857
882
  framework.start();
858
883
  setupSynesthete(app);
859
884
  setupMcplStderrLog(app, storePath);
@@ -27,11 +27,13 @@ import type {
27
27
  StreamCallbacks,
28
28
  } from '@animalabs/membrane';
29
29
  import { appendFileSync } from 'node:fs';
30
+ import { summarizeCacheControls, type ProviderCallRecord } from './call-ledger.js';
30
31
 
31
32
  /** Live read of the current reasoning setting. The host wires this to
32
- * `SettingsModule.getReasoning()` so toggles via the `settings--reasoning_*`
33
- * tools take effect on the next call without restart. */
33
+ * `SettingsModule.getReasoning()` so toggles via the `agent_settings` tool's
34
+ * reasoning_enabled field take effect on the next call without restart. */
34
35
  export type ReasoningGetter = () => { enabled: boolean; budgetTokens: number };
36
+ export type ProviderCallObserver = (record: ProviderCallRecord) => void;
35
37
 
36
38
  /** Exact first-system-block identity Anthropic requires on subscription
37
39
  * (sk-ant-oat…) OAuth traffic. Verified 2026-07-09: any other first block —
@@ -44,6 +46,7 @@ const OAUTH_SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for
44
46
  export class LoggingAnthropicAdapter extends AnthropicAdapter {
45
47
  private readonly logPath: string;
46
48
  private readonly getReasoning?: ReasoningGetter;
49
+ private readonly onCall?: ProviderCallObserver;
47
50
  /** True when authenticated with an OAuth/Bearer token instead of an API
48
51
  * key; requests then need the identity block prepended (see above). */
49
52
  private readonly oauthMode: boolean;
@@ -52,10 +55,12 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
52
55
  config: ConstructorParameters<typeof AnthropicAdapter>[0],
53
56
  logPath: string,
54
57
  getReasoning?: ReasoningGetter,
58
+ onCall?: ProviderCallObserver,
55
59
  ) {
56
60
  super(config);
57
61
  this.logPath = logPath;
58
62
  this.getReasoning = getReasoning;
63
+ this.onCall = onCall;
59
64
  this.oauthMode = Boolean(config?.authToken);
60
65
  }
61
66
 
@@ -90,7 +95,7 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
90
95
  // form with: `"thinking.type.enabled" is not supported for this model.
91
96
  // Use "thinking.type.adaptive"`. Under adaptive the model sizes its own
92
97
  // thinking, so `budgetTokens` is no longer sent (it still shows in
93
- // reasoning_status as an informational hint).
98
+ // agent_settings as an informational hint).
94
99
  //
95
100
  // membrane's `ProviderRequest` doesn't type a top-level `thinking` field
96
101
  // (membrane is 0.5.68 — agent-framework is the package that went 0.6.0;
@@ -115,15 +120,91 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
115
120
  }
116
121
  }
117
122
 
118
- private requestSummary(request: ProviderRequest): Record<string, unknown> {
123
+ private requestSummary(request: ProviderRequest, rawRequest?: unknown): Record<string, unknown> {
124
+ const cache = rawRequest ? summarizeCacheControls(rawRequest) : undefined;
119
125
  return {
120
126
  model: request.model,
121
127
  maxTokens: request.maxTokens,
122
128
  messages: request.messages.length,
123
129
  tools: request.tools?.length ?? 0,
130
+ ...(cache ? { cacheBreakpoints: cache.count, cacheTtls: cache.ttls } : {}),
124
131
  };
125
132
  }
126
133
 
134
+ /**
135
+ * Off-path refusal dragnet (observability M3). The main inference driver
136
+ * instruments its own refusals (agent-framework noteRefusal), but that
137
+ * covers ONLY streamed agent turns. Every other model call in the process
138
+ * — compression/summarizer drains, maintenance — flows through this
139
+ * adapter's complete() and previously refused in silence; the 2026-07-15
140
+ * mythos cascade STARTED there and alerted nowhere. Fired for
141
+ * kind==='complete' only: agent turns stream, so this never double-reports
142
+ * a refusal the main driver already escalated.
143
+ */
144
+ onRefusal?: (info: {
145
+ kind: 'complete' | 'stream';
146
+ category?: string;
147
+ model: string;
148
+ messages: number;
149
+ inputTokens: number;
150
+ }) => void;
151
+
152
+ private observeCall(
153
+ kind: 'complete' | 'stream',
154
+ timestamp: string,
155
+ durationMs: number,
156
+ request: ProviderRequest,
157
+ rawRequest: unknown,
158
+ response?: ProviderResponse,
159
+ error?: unknown,
160
+ ): void {
161
+ const raw0 = (response as { raw?: { stop_reason?: string; stop_details?: { category?: string } } } | undefined)?.raw;
162
+ if (kind === 'complete' && raw0?.stop_reason === 'refusal' && this.onRefusal) {
163
+ try {
164
+ this.onRefusal({
165
+ kind,
166
+ category: raw0.stop_details?.category,
167
+ model: request.model,
168
+ messages: request.messages.length,
169
+ inputTokens: response?.usage.inputTokens ?? 0,
170
+ });
171
+ } catch { /* observers never affect provider traffic */ }
172
+ }
173
+ if (!this.onCall) return;
174
+ const cache = summarizeCacheControls(rawRequest);
175
+ const raw = (response as { raw?: Record<string, unknown> } | undefined)?.raw;
176
+ const rawUsage = raw?.usage as Record<string, unknown> | undefined;
177
+ const cacheCreation = rawUsage?.cache_creation as Record<string, unknown> | undefined;
178
+ const finite = (value: unknown): number | undefined =>
179
+ typeof value === 'number' && Number.isFinite(value) ? value : undefined;
180
+ const text = (value: unknown): string | undefined =>
181
+ typeof value === 'string' && value.length > 0 ? value : undefined;
182
+ try {
183
+ this.onCall({
184
+ timestamp,
185
+ kind,
186
+ durationMs,
187
+ model: request.model,
188
+ messages: request.messages.length,
189
+ inputTokens: response?.usage.inputTokens ?? 0,
190
+ outputTokens: response?.usage.outputTokens ?? 0,
191
+ cacheReadTokens: response?.usage.cacheReadTokens ?? 0,
192
+ cacheWriteTokens: response?.usage.cacheCreationTokens ?? 0,
193
+ cacheWrite5mTokens: finite(cacheCreation?.ephemeral_5m_input_tokens),
194
+ cacheWrite1hTokens: finite(cacheCreation?.ephemeral_1h_input_tokens),
195
+ cacheWriteBucketsAuthoritative: cacheCreation !== undefined,
196
+ inferenceGeo: text(rawUsage?.inference_geo) ?? text(raw?.inference_geo),
197
+ serviceTier: text(rawUsage?.service_tier) ?? text(raw?.service_tier),
198
+ cacheBreakpoints: cache.count,
199
+ cacheTtls: cache.ttls,
200
+ stopReason: response?.stopReason,
201
+ error: error instanceof Error ? error.message : error ? String(error) : undefined,
202
+ });
203
+ } catch {
204
+ // A dashboard observer is never allowed to affect provider traffic.
205
+ }
206
+ }
207
+
127
208
  private refusalRawRequest(response: ProviderResponse, rawRequest: unknown): unknown {
128
209
  const raw = (response as { raw?: { stop_reason?: string } }).raw;
129
210
  return raw?.stop_reason === 'refusal' ? rawRequest : undefined;
@@ -155,22 +236,28 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
155
236
  const wrapped = this.captureRawRequest(options, sink);
156
237
  try {
157
238
  const response = await super.complete(effective, wrapped);
239
+ const timestamp = new Date().toISOString();
240
+ const durationMs = Date.now() - t0;
158
241
  this.log({
159
242
  type: 'call', kind: 'complete',
160
- timestamp: new Date().toISOString(), durationMs: Date.now() - t0,
161
- requestSummary: this.requestSummary(request),
243
+ timestamp, durationMs,
244
+ requestSummary: this.requestSummary(request, sink.rawRequest),
162
245
  rawRequest: this.refusalRawRequest(response, sink.rawRequest),
163
246
  rawResponse: (response as { raw?: unknown }).raw ?? null,
164
247
  });
248
+ this.observeCall('complete', timestamp, durationMs, request, sink.rawRequest, response);
165
249
  return response;
166
250
  } catch (err) {
251
+ const timestamp = new Date().toISOString();
252
+ const durationMs = Date.now() - t0;
167
253
  this.log({
168
254
  type: 'error', kind: 'complete',
169
- timestamp: new Date().toISOString(), durationMs: Date.now() - t0,
170
- requestSummary: this.requestSummary(request),
255
+ timestamp, durationMs,
256
+ requestSummary: this.requestSummary(request, sink.rawRequest),
171
257
  rawRequest: sink.rawRequest,
172
258
  error: err instanceof Error ? { name: err.name, message: err.message, stack: err.stack } : String(err),
173
259
  });
260
+ this.observeCall('complete', timestamp, durationMs, request, sink.rawRequest, undefined, err);
174
261
  throw err;
175
262
  }
176
263
  }
@@ -186,22 +273,28 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
186
273
  const wrapped = this.captureRawRequest(options, sink);
187
274
  try {
188
275
  const response = await super.stream(effective, callbacks, wrapped);
276
+ const timestamp = new Date().toISOString();
277
+ const durationMs = Date.now() - t0;
189
278
  this.log({
190
279
  type: 'call', kind: 'stream',
191
- timestamp: new Date().toISOString(), durationMs: Date.now() - t0,
192
- requestSummary: this.requestSummary(request),
280
+ timestamp, durationMs,
281
+ requestSummary: this.requestSummary(request, sink.rawRequest),
193
282
  rawRequest: this.refusalRawRequest(response, sink.rawRequest),
194
283
  rawResponse: (response as { raw?: unknown }).raw ?? null,
195
284
  });
285
+ this.observeCall('stream', timestamp, durationMs, request, sink.rawRequest, response);
196
286
  return response;
197
287
  } catch (err) {
288
+ const timestamp = new Date().toISOString();
289
+ const durationMs = Date.now() - t0;
198
290
  this.log({
199
291
  type: 'error', kind: 'stream',
200
- timestamp: new Date().toISOString(), durationMs: Date.now() - t0,
201
- requestSummary: this.requestSummary(request),
292
+ timestamp, durationMs,
293
+ requestSummary: this.requestSummary(request, sink.rawRequest),
202
294
  rawRequest: sink.rawRequest,
203
295
  error: err instanceof Error ? { name: err.name, message: err.message, stack: err.stack } : String(err),
204
296
  });
297
+ this.observeCall('stream', timestamp, durationMs, request, sink.rawRequest, undefined, err);
205
298
  throw err;
206
299
  }
207
300
  }
@@ -25,6 +25,7 @@ export interface ServerFileEntry {
25
25
  disabledFeatureSets?: string[];
26
26
  enabledTools?: string[];
27
27
  disabledTools?: string[];
28
+ /** @deprecated One-time migration input for legacy installations. */
28
29
  channelSubscription?: 'auto' | 'manual' | string[];
29
30
  }
30
31