@animalabs/connectome-host 0.3.5 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +6 -0
- package/.github/workflows/ci.yml +52 -0
- package/.github/workflows/publish.yml +2 -2
- package/README.md +5 -0
- package/bun.lock +14 -6
- package/docs/AGENT-ONBOARDING.md +6 -1
- package/package.json +6 -4
- package/scripts/import-codex-rollout.ts +288 -0
- package/src/call-ledger.ts +371 -0
- package/src/call-pricing.ts +119 -0
- package/src/framework-agent-config.ts +51 -0
- package/src/framework-strategy.ts +70 -0
- package/src/index.ts +130 -105
- package/src/logging-adapter.ts +105 -12
- package/src/mcpl-config.ts +1 -0
- package/src/modules/channel-mode-module.ts +14 -16
- package/src/modules/fleet-module.ts +17 -7
- package/src/modules/mcpl-admin-module.ts +6 -15
- package/src/modules/settings-module.ts +83 -59
- package/src/modules/subscription-gc-module.ts +17 -20
- package/src/modules/time-module.ts +15 -9
- package/src/modules/web-ui-module.ts +88 -10
- package/src/modules/web-ui-observers.ts +35 -9
- package/src/recipe.ts +133 -6
- package/src/state/agent-tree-reducer.ts +17 -3
- package/src/strategies/frontdesk-strategy.ts +15 -9
- package/src/web/protocol.ts +89 -0
- package/test/agent-tree-reducer.test.ts +35 -3
- package/test/autobio-progress-snapshot.test.ts +27 -12
- package/test/call-ledger.test.ts +91 -0
- package/test/call-pricing.test.ts +69 -0
- package/test/framework-fkm-composition.test.ts +160 -0
- package/test/frontdesk-strategy.test.ts +10 -0
- package/test/import-codex-rollout.test.ts +36 -0
- package/test/logging-adapter-refusal.test.ts +57 -0
- package/test/logging-adapter.test.ts +58 -1
- package/test/recipe-cache-ttl.test.ts +7 -3
- package/test/recipe-compression-fallback.test.ts +36 -0
- package/test/recipe-primary-summary-fallback.test.ts +40 -0
- package/test/recipe-provider.test.ts +36 -0
- package/test/recipe-think-policy.test.ts +39 -0
- package/test/recipe-timezone.test.ts +20 -0
- package/test/subscription-gc-module.test.ts +7 -5
- package/test/time-module.test.ts +13 -0
- package/test/web-ui-observers.test.ts +70 -2
- package/web/package-lock.json +425 -302
- package/web/src/App.tsx +9 -0
- package/web/src/ObserverGate.tsx +21 -11
- package/web/src/Usage.tsx +116 -2
- package/web/src/wire.ts +5 -2
- package/web/bun.lock +0 -357
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 {
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 =
|
|
773
|
-
{
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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:
|
|
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);
|
package/src/logging-adapter.ts
CHANGED
|
@@ -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 `
|
|
33
|
-
*
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
package/src/mcpl-config.ts
CHANGED
|
@@ -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
|
|