@animalabs/connectome-host 0.3.5 → 0.3.7
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/README.md +5 -0
- package/bun.lock +8 -4
- package/docs/AGENT-ONBOARDING.md +6 -1
- package/package.json +3 -3
- package/scripts/import-codex-rollout.ts +288 -0
- package/src/call-ledger.ts +371 -0
- package/src/call-pricing.ts +119 -0
- package/src/index.ts +78 -27
- package/src/logging-adapter.ts +72 -9
- package/src/modules/fleet-module.ts +17 -7
- package/src/modules/mcpl-admin-module.ts +6 -0
- package/src/modules/time-module.ts +15 -9
- package/src/modules/web-ui-module.ts +25 -0
- package/src/modules/web-ui-observers.ts +16 -3
- package/src/recipe.ts +48 -3
- package/src/strategies/frontdesk-strategy.ts +10 -4
- package/src/web/protocol.ts +89 -0
- package/test/call-ledger.test.ts +91 -0
- package/test/call-pricing.test.ts +69 -0
- package/test/frontdesk-strategy.test.ts +10 -0
- package/test/import-codex-rollout.test.ts +36 -0
- package/test/logging-adapter.test.ts +58 -1
- package/test/recipe-cache-ttl.test.ts +7 -3
- package/test/recipe-provider.test.ts +36 -0
- package/test/recipe-timezone.test.ts +20 -0
- package/test/time-module.test.ts +13 -0
- package/test/web-ui-observers.test.ts +43 -0
- 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/src/index.ts
CHANGED
|
@@ -15,10 +15,16 @@
|
|
|
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, AutobiographicalStrategy, PassthroughStrategy, WorkspaceModule, type Module, type MountConfig } from '@animalabs/agent-framework';
|
|
27
|
+
import { AgentFramework, AutobiographicalStrategy, PassthroughStrategy, 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';
|
|
@@ -61,15 +67,11 @@ const config = {
|
|
|
61
67
|
// OAuth/Bearer token (e.g. a Claude subscription token). When set, it takes
|
|
62
68
|
// precedence over the API key so requests never carry both auth schemes.
|
|
63
69
|
authToken: process.env.ANTHROPIC_AUTH_TOKEN,
|
|
70
|
+
openaiApiKey: process.env.OPENAI_API_KEY,
|
|
64
71
|
model: process.env.MODEL,
|
|
65
72
|
dataDir: process.env.DATA_DIR || './data',
|
|
66
73
|
};
|
|
67
74
|
|
|
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
75
|
// ---------------------------------------------------------------------------
|
|
74
76
|
// AppContext — mutable container for session switching
|
|
75
77
|
// ---------------------------------------------------------------------------
|
|
@@ -138,14 +140,16 @@ async function createFramework(
|
|
|
138
140
|
recipe: Recipe,
|
|
139
141
|
agentName: string,
|
|
140
142
|
settingsModule: SettingsModule,
|
|
143
|
+
callLedger: CallLedger | null,
|
|
141
144
|
): Promise<AgentFramework> {
|
|
142
145
|
const model = config.model || recipe.agent.model || 'claude-opus-4-6';
|
|
143
146
|
const modules = recipe.modules ?? {};
|
|
147
|
+
const timeZone = resolveTimeZone(recipe.agent.timezone);
|
|
144
148
|
|
|
145
149
|
// -- Build module list --
|
|
146
150
|
// SettingsModule is constructed in main() (before the adapter, so the
|
|
147
151
|
// adapter can read its state for cross-cutting concerns like reasoning).
|
|
148
|
-
const moduleInstances: Module[] = [new TuiModule(), new TimeModule(), settingsModule];
|
|
152
|
+
const moduleInstances: Module[] = [new TuiModule(), new TimeModule(timeZone), settingsModule];
|
|
149
153
|
|
|
150
154
|
// Subagents
|
|
151
155
|
let subagentModule: SubagentModule | null = null;
|
|
@@ -169,10 +173,11 @@ async function createFramework(
|
|
|
169
173
|
|
|
170
174
|
// Fleet (cross-process child orchestration). Opt-in via recipe.
|
|
171
175
|
if (modules.fleet === true) {
|
|
172
|
-
moduleInstances.push(new FleetModule());
|
|
176
|
+
moduleInstances.push(new FleetModule({ timeZone }));
|
|
173
177
|
} else if (modules.fleet && typeof modules.fleet === 'object') {
|
|
174
178
|
const fleetCfg = modules.fleet;
|
|
175
179
|
const fleetModuleConfig: FleetModuleConfig = {};
|
|
180
|
+
fleetModuleConfig.timeZone = timeZone;
|
|
176
181
|
if (fleetCfg.children) {
|
|
177
182
|
fleetModuleConfig.autoStart = fleetCfg.children.map((c) => {
|
|
178
183
|
const entry: NonNullable<FleetModuleConfig['autoStart']>[number] = {
|
|
@@ -310,7 +315,7 @@ async function createFramework(
|
|
|
310
315
|
// ability to spawn arbitrary commands via mcpl_deploy; see recipe.ts).
|
|
311
316
|
let mcplAdminModule: McplAdminModule | null = null;
|
|
312
317
|
if (modules.mcplAdmin === true) {
|
|
313
|
-
mcplAdminModule = new McplAdminModule();
|
|
318
|
+
mcplAdminModule = new McplAdminModule({ timeZone });
|
|
314
319
|
moduleInstances.push(mcplAdminModule);
|
|
315
320
|
}
|
|
316
321
|
|
|
@@ -330,6 +335,7 @@ async function createFramework(
|
|
|
330
335
|
basicAuth: webuiConfig.basicAuth,
|
|
331
336
|
allowedOrigins: webuiConfig.allowedOrigins,
|
|
332
337
|
observersPath,
|
|
338
|
+
...(callLedger ? { callLedger } : {}),
|
|
333
339
|
});
|
|
334
340
|
moduleInstances.push(webUiModule);
|
|
335
341
|
moduleInstances.push(new ObserversModule({ path: observersPath }));
|
|
@@ -384,7 +390,12 @@ async function createFramework(
|
|
|
384
390
|
// Apply the agent overlay (mcpl-servers.agent.json): servers the agent
|
|
385
391
|
// deployed for itself load unconditionally (no recipe opt-in), and
|
|
386
392
|
// tombstones suppress recipe/file servers the agent unloaded.
|
|
387
|
-
const finalServers = applyAgentOverlay(allServers, DEFAULT_AGENT_OVERLAY_PATH)
|
|
393
|
+
const finalServers = applyAgentOverlay(allServers, DEFAULT_AGENT_OVERLAY_PATH).map((server) => ({
|
|
394
|
+
...server,
|
|
395
|
+
// Stdio MCPL children inherit a single agent-facing wall clock. Protocol
|
|
396
|
+
// timestamps remain UTC; only their rendered text uses this setting.
|
|
397
|
+
env: { ...(server.env ?? {}), AGENT_TIMEZONE: timeZone },
|
|
398
|
+
}));
|
|
388
399
|
|
|
389
400
|
// No server augmentation needed — gate is wired via FrameworkConfig.gate
|
|
390
401
|
|
|
@@ -405,6 +416,7 @@ async function createFramework(
|
|
|
405
416
|
compressionModel: strategyConfig?.compressionModel ?? model,
|
|
406
417
|
autoTickOnNewMessage: true,
|
|
407
418
|
maxMessageTokens: strategyConfig?.maxMessageTokens ?? 10000,
|
|
419
|
+
...(strategyType === 'frontdesk' ? { timeZone } : {}),
|
|
408
420
|
};
|
|
409
421
|
// Forward optional tuning fields when set. The key list is typed
|
|
410
422
|
// against `RecipeStrategy`, so an unknown field name is a compile
|
|
@@ -465,6 +477,20 @@ async function createFramework(
|
|
|
465
477
|
maxStreamTokens: recipe.agent.maxStreamTokens ?? 150000,
|
|
466
478
|
contextBudgetTokens: recipe.agent.contextBudgetTokens,
|
|
467
479
|
...(recipe.agent.cacheTtl && { cacheTtl: recipe.agent.cacheTtl }),
|
|
480
|
+
...(recipe.agent.provider === 'openai-responses' && {
|
|
481
|
+
providerParams: {
|
|
482
|
+
reasoning: {
|
|
483
|
+
effort: recipe.agent.responses?.reasoningEffort ?? 'high',
|
|
484
|
+
context: recipe.agent.responses?.reasoningContext ?? 'all_turns',
|
|
485
|
+
},
|
|
486
|
+
...(recipe.agent.responses?.compactThreshold ? {
|
|
487
|
+
context_management: [{
|
|
488
|
+
type: 'compaction',
|
|
489
|
+
compact_threshold: recipe.agent.responses.compactThreshold,
|
|
490
|
+
}],
|
|
491
|
+
} : {}),
|
|
492
|
+
},
|
|
493
|
+
}),
|
|
468
494
|
strategy,
|
|
469
495
|
...(recipe.agent.thinking && { thinking: recipe.agent.thinking }),
|
|
470
496
|
...(recipe.agent.refusalHandling && { refusalHandling: recipe.agent.refusalHandling }),
|
|
@@ -473,6 +499,7 @@ async function createFramework(
|
|
|
473
499
|
modules: moduleInstances,
|
|
474
500
|
mcplServers: finalServers,
|
|
475
501
|
gate: gateOptions,
|
|
502
|
+
timeZone,
|
|
476
503
|
});
|
|
477
504
|
|
|
478
505
|
// Wire post-creation hooks
|
|
@@ -752,6 +779,16 @@ function countLines(path: string): number {
|
|
|
752
779
|
|
|
753
780
|
async function main() {
|
|
754
781
|
const recipe = await resolveRecipe();
|
|
782
|
+
const provider = recipe.agent.provider ?? 'anthropic';
|
|
783
|
+
|
|
784
|
+
if (provider === 'openai-responses' && !config.openaiApiKey) {
|
|
785
|
+
console.error('Missing OPENAI_API_KEY for recipe provider "openai-responses".');
|
|
786
|
+
process.exit(1);
|
|
787
|
+
}
|
|
788
|
+
if (provider === 'anthropic' && !config.apiKey && !config.authToken) {
|
|
789
|
+
console.error('Missing ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN). Set one in .env or environment.');
|
|
790
|
+
process.exit(1);
|
|
791
|
+
}
|
|
755
792
|
|
|
756
793
|
// SettingsModule constructed early so the adapter can read its state for
|
|
757
794
|
// cross-cutting concerns (currently: reasoning). It's wired into the
|
|
@@ -766,22 +803,34 @@ async function main() {
|
|
|
766
803
|
config.dataDir,
|
|
767
804
|
`llm-calls.${new Date().toISOString().replace(/[:.]/g, '-')}.jsonl`,
|
|
768
805
|
);
|
|
806
|
+
const callLedger = provider === 'anthropic'
|
|
807
|
+
? new CallLedger({
|
|
808
|
+
dataDir: config.dataDir,
|
|
809
|
+
defaultTtl: recipe.agent.cacheTtl ?? '5m',
|
|
810
|
+
})
|
|
811
|
+
: null;
|
|
769
812
|
// OAuth (subscription) auth wins over API-key auth when both are present.
|
|
770
813
|
// Subscription tokens (sk-ant-oat…) additionally require the oauth beta
|
|
771
814
|
// header on every request.
|
|
772
|
-
const adapter =
|
|
773
|
-
{
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
815
|
+
const adapter = provider === 'openai-responses'
|
|
816
|
+
? new OpenAIResponsesAPIAdapter({
|
|
817
|
+
apiKey: config.openaiApiKey!,
|
|
818
|
+
baseURL: process.env.OPENAI_BASE_URL || undefined,
|
|
819
|
+
})
|
|
820
|
+
: new LoggingAnthropicAdapter(
|
|
821
|
+
{
|
|
822
|
+
...(config.authToken
|
|
823
|
+
? {
|
|
824
|
+
authToken: config.authToken,
|
|
825
|
+
defaultHeaders: { 'anthropic-beta': 'oauth-2025-04-20' },
|
|
826
|
+
}
|
|
827
|
+
: { apiKey: config.apiKey! }),
|
|
828
|
+
baseURL: process.env.ANTHROPIC_BASE_URL || undefined,
|
|
829
|
+
},
|
|
830
|
+
llmLogPath,
|
|
831
|
+
() => settingsModule.getReasoning(),
|
|
832
|
+
(record) => callLedger!.record(record),
|
|
833
|
+
);
|
|
785
834
|
|
|
786
835
|
// Session management — resolved before Membrane construction so the
|
|
787
836
|
// active session's import-source sidecar can contribute to agent-name
|
|
@@ -814,7 +863,9 @@ async function main() {
|
|
|
814
863
|
const agentName = resolved.name;
|
|
815
864
|
|
|
816
865
|
const membrane = new Membrane(adapter, {
|
|
817
|
-
formatter:
|
|
866
|
+
formatter: provider === 'openai-responses'
|
|
867
|
+
? new OpenAIResponsesFormatter()
|
|
868
|
+
: new NativeFormatter(),
|
|
818
869
|
// Anchor the assistant role for internal callers that don't set
|
|
819
870
|
// request.assistantParticipant themselves (autobio compression,
|
|
820
871
|
// executeMerge). Mismatch here flips stored assistant turns to
|
|
@@ -823,7 +874,7 @@ async function main() {
|
|
|
823
874
|
});
|
|
824
875
|
|
|
825
876
|
const storePath = sessionManager.getStorePath(activeSession.id);
|
|
826
|
-
const framework = await createFramework(membrane, storePath, recipe, agentName, settingsModule);
|
|
877
|
+
const framework = await createFramework(membrane, storePath, recipe, agentName, settingsModule, callLedger);
|
|
827
878
|
|
|
828
879
|
// Build app context
|
|
829
880
|
const app: AppContext = {
|
|
@@ -844,7 +895,7 @@ async function main() {
|
|
|
844
895
|
// re-resolution would matter only if recipe.agent.name is absent
|
|
845
896
|
// AND the user switches between imports that used different
|
|
846
897
|
// --agent values; not the canonical flow.
|
|
847
|
-
this.framework = await createFramework(membrane, newStorePath, recipe, this.agentName, settingsModule);
|
|
898
|
+
this.framework = await createFramework(membrane, newStorePath, recipe, this.agentName, settingsModule, callLedger);
|
|
848
899
|
this.framework.start();
|
|
849
900
|
this.userMessageCount = 0;
|
|
850
901
|
resetBranchState(this.branchState);
|
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
33
|
* `SettingsModule.getReasoning()` so toggles via the `settings--reasoning_*`
|
|
33
34
|
* tools 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
|
|
|
@@ -115,15 +120,61 @@ 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
|
+
private observeCall(
|
|
135
|
+
kind: 'complete' | 'stream',
|
|
136
|
+
timestamp: string,
|
|
137
|
+
durationMs: number,
|
|
138
|
+
request: ProviderRequest,
|
|
139
|
+
rawRequest: unknown,
|
|
140
|
+
response?: ProviderResponse,
|
|
141
|
+
error?: unknown,
|
|
142
|
+
): void {
|
|
143
|
+
if (!this.onCall) return;
|
|
144
|
+
const cache = summarizeCacheControls(rawRequest);
|
|
145
|
+
const raw = (response as { raw?: Record<string, unknown> } | undefined)?.raw;
|
|
146
|
+
const rawUsage = raw?.usage as Record<string, unknown> | undefined;
|
|
147
|
+
const cacheCreation = rawUsage?.cache_creation as Record<string, unknown> | undefined;
|
|
148
|
+
const finite = (value: unknown): number | undefined =>
|
|
149
|
+
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
150
|
+
const text = (value: unknown): string | undefined =>
|
|
151
|
+
typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
152
|
+
try {
|
|
153
|
+
this.onCall({
|
|
154
|
+
timestamp,
|
|
155
|
+
kind,
|
|
156
|
+
durationMs,
|
|
157
|
+
model: request.model,
|
|
158
|
+
messages: request.messages.length,
|
|
159
|
+
inputTokens: response?.usage.inputTokens ?? 0,
|
|
160
|
+
outputTokens: response?.usage.outputTokens ?? 0,
|
|
161
|
+
cacheReadTokens: response?.usage.cacheReadTokens ?? 0,
|
|
162
|
+
cacheWriteTokens: response?.usage.cacheCreationTokens ?? 0,
|
|
163
|
+
cacheWrite5mTokens: finite(cacheCreation?.ephemeral_5m_input_tokens),
|
|
164
|
+
cacheWrite1hTokens: finite(cacheCreation?.ephemeral_1h_input_tokens),
|
|
165
|
+
cacheWriteBucketsAuthoritative: cacheCreation !== undefined,
|
|
166
|
+
inferenceGeo: text(rawUsage?.inference_geo) ?? text(raw?.inference_geo),
|
|
167
|
+
serviceTier: text(rawUsage?.service_tier) ?? text(raw?.service_tier),
|
|
168
|
+
cacheBreakpoints: cache.count,
|
|
169
|
+
cacheTtls: cache.ttls,
|
|
170
|
+
stopReason: response?.stopReason,
|
|
171
|
+
error: error instanceof Error ? error.message : error ? String(error) : undefined,
|
|
172
|
+
});
|
|
173
|
+
} catch {
|
|
174
|
+
// A dashboard observer is never allowed to affect provider traffic.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
127
178
|
private refusalRawRequest(response: ProviderResponse, rawRequest: unknown): unknown {
|
|
128
179
|
const raw = (response as { raw?: { stop_reason?: string } }).raw;
|
|
129
180
|
return raw?.stop_reason === 'refusal' ? rawRequest : undefined;
|
|
@@ -155,22 +206,28 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
|
|
|
155
206
|
const wrapped = this.captureRawRequest(options, sink);
|
|
156
207
|
try {
|
|
157
208
|
const response = await super.complete(effective, wrapped);
|
|
209
|
+
const timestamp = new Date().toISOString();
|
|
210
|
+
const durationMs = Date.now() - t0;
|
|
158
211
|
this.log({
|
|
159
212
|
type: 'call', kind: 'complete',
|
|
160
|
-
timestamp
|
|
161
|
-
requestSummary: this.requestSummary(request),
|
|
213
|
+
timestamp, durationMs,
|
|
214
|
+
requestSummary: this.requestSummary(request, sink.rawRequest),
|
|
162
215
|
rawRequest: this.refusalRawRequest(response, sink.rawRequest),
|
|
163
216
|
rawResponse: (response as { raw?: unknown }).raw ?? null,
|
|
164
217
|
});
|
|
218
|
+
this.observeCall('complete', timestamp, durationMs, request, sink.rawRequest, response);
|
|
165
219
|
return response;
|
|
166
220
|
} catch (err) {
|
|
221
|
+
const timestamp = new Date().toISOString();
|
|
222
|
+
const durationMs = Date.now() - t0;
|
|
167
223
|
this.log({
|
|
168
224
|
type: 'error', kind: 'complete',
|
|
169
|
-
timestamp
|
|
170
|
-
requestSummary: this.requestSummary(request),
|
|
225
|
+
timestamp, durationMs,
|
|
226
|
+
requestSummary: this.requestSummary(request, sink.rawRequest),
|
|
171
227
|
rawRequest: sink.rawRequest,
|
|
172
228
|
error: err instanceof Error ? { name: err.name, message: err.message, stack: err.stack } : String(err),
|
|
173
229
|
});
|
|
230
|
+
this.observeCall('complete', timestamp, durationMs, request, sink.rawRequest, undefined, err);
|
|
174
231
|
throw err;
|
|
175
232
|
}
|
|
176
233
|
}
|
|
@@ -186,22 +243,28 @@ export class LoggingAnthropicAdapter extends AnthropicAdapter {
|
|
|
186
243
|
const wrapped = this.captureRawRequest(options, sink);
|
|
187
244
|
try {
|
|
188
245
|
const response = await super.stream(effective, callbacks, wrapped);
|
|
246
|
+
const timestamp = new Date().toISOString();
|
|
247
|
+
const durationMs = Date.now() - t0;
|
|
189
248
|
this.log({
|
|
190
249
|
type: 'call', kind: 'stream',
|
|
191
|
-
timestamp
|
|
192
|
-
requestSummary: this.requestSummary(request),
|
|
250
|
+
timestamp, durationMs,
|
|
251
|
+
requestSummary: this.requestSummary(request, sink.rawRequest),
|
|
193
252
|
rawRequest: this.refusalRawRequest(response, sink.rawRequest),
|
|
194
253
|
rawResponse: (response as { raw?: unknown }).raw ?? null,
|
|
195
254
|
});
|
|
255
|
+
this.observeCall('stream', timestamp, durationMs, request, sink.rawRequest, response);
|
|
196
256
|
return response;
|
|
197
257
|
} catch (err) {
|
|
258
|
+
const timestamp = new Date().toISOString();
|
|
259
|
+
const durationMs = Date.now() - t0;
|
|
198
260
|
this.log({
|
|
199
261
|
type: 'error', kind: 'stream',
|
|
200
|
-
timestamp
|
|
201
|
-
requestSummary: this.requestSummary(request),
|
|
262
|
+
timestamp, durationMs,
|
|
263
|
+
requestSummary: this.requestSummary(request, sink.rawRequest),
|
|
202
264
|
rawRequest: sink.rawRequest,
|
|
203
265
|
error: err instanceof Error ? { name: err.name, message: err.message, stack: err.stack } : String(err),
|
|
204
266
|
});
|
|
267
|
+
this.observeCall('stream', timestamp, durationMs, request, sink.rawRequest, undefined, err);
|
|
205
268
|
throw err;
|
|
206
269
|
}
|
|
207
270
|
}
|
|
@@ -29,6 +29,7 @@ import type {
|
|
|
29
29
|
ToolCall,
|
|
30
30
|
ToolResult,
|
|
31
31
|
} from '@animalabs/agent-framework';
|
|
32
|
+
import { formatZonedDateTime, resolveTimeZone } from '@animalabs/agent-framework';
|
|
32
33
|
import { spawn as spawnProcess, type ChildProcess } from 'node:child_process';
|
|
33
34
|
import { connect as netConnect, type Socket } from 'node:net';
|
|
34
35
|
import { existsSync, mkdirSync, unlinkSync, openSync, closeSync, appendFileSync, realpathSync } from 'node:fs';
|
|
@@ -81,6 +82,8 @@ interface FleetEventSubscription {
|
|
|
81
82
|
// ---------------------------------------------------------------------------
|
|
82
83
|
|
|
83
84
|
export interface FleetModuleConfig {
|
|
85
|
+
/** IANA zone for wall-clock timestamps returned by fleet tools. */
|
|
86
|
+
timeZone?: string;
|
|
84
87
|
/** Default subscription sent to children at handshake (default: ['*']). */
|
|
85
88
|
defaultSubscription?: string[];
|
|
86
89
|
/** Max events to retain per child for peek (default: 500). */
|
|
@@ -255,6 +258,7 @@ export class FleetModule implements Module {
|
|
|
255
258
|
sigtermEscalationMs: config.sigtermEscalationMs ?? 5_000,
|
|
256
259
|
childIndexPath: config.childIndexPath ?? process.argv[1] ?? '',
|
|
257
260
|
childRuntimePath: config.childRuntimePath ?? process.execPath,
|
|
261
|
+
timeZone: resolveTimeZone(config.timeZone),
|
|
258
262
|
};
|
|
259
263
|
|
|
260
264
|
this.autoStartChildren = config.autoStart ?? [];
|
|
@@ -1014,7 +1018,12 @@ export class FleetModule implements Module {
|
|
|
1014
1018
|
{
|
|
1015
1019
|
detached: true,
|
|
1016
1020
|
stdio: startupFd !== null ? ['ignore', startupFd, startupFd] : 'ignore',
|
|
1017
|
-
env: {
|
|
1021
|
+
env: {
|
|
1022
|
+
...process.env,
|
|
1023
|
+
...input.env,
|
|
1024
|
+
AGENT_TIMEZONE: this.config.timeZone,
|
|
1025
|
+
DATA_DIR: dataDir,
|
|
1026
|
+
},
|
|
1018
1027
|
},
|
|
1019
1028
|
);
|
|
1020
1029
|
proc.unref();
|
|
@@ -1095,9 +1104,9 @@ export class FleetModule implements Module {
|
|
|
1095
1104
|
name: c.name,
|
|
1096
1105
|
status: c.status,
|
|
1097
1106
|
pid: c.pid,
|
|
1098
|
-
startedAt: c.startedAt,
|
|
1099
|
-
exitedAt: c.exitedAt,
|
|
1100
|
-
lastEventAt: c.lastEventAt,
|
|
1107
|
+
startedAt: formatZonedDateTime(c.startedAt, this.config.timeZone),
|
|
1108
|
+
exitedAt: c.exitedAt === null ? null : formatZonedDateTime(c.exitedAt, this.config.timeZone),
|
|
1109
|
+
lastEventAt: c.lastEventAt === null ? null : formatZonedDateTime(c.lastEventAt, this.config.timeZone),
|
|
1101
1110
|
eventCount: c.events.length,
|
|
1102
1111
|
}));
|
|
1103
1112
|
return { success: true, data: list };
|
|
@@ -1120,9 +1129,10 @@ export class FleetModule implements Module {
|
|
|
1120
1129
|
socketPath: c.socketPath,
|
|
1121
1130
|
pid: c.pid,
|
|
1122
1131
|
status: c.status,
|
|
1123
|
-
startedAt: c.startedAt,
|
|
1124
|
-
exitedAt: c.exitedAt,
|
|
1125
|
-
lastEventAt: c.lastEventAt,
|
|
1132
|
+
startedAt: formatZonedDateTime(c.startedAt, this.config.timeZone),
|
|
1133
|
+
exitedAt: c.exitedAt === null ? null : formatZonedDateTime(c.exitedAt, this.config.timeZone),
|
|
1134
|
+
lastEventAt: c.lastEventAt === null ? null : formatZonedDateTime(c.lastEventAt, this.config.timeZone),
|
|
1135
|
+
timeZone: this.config.timeZone,
|
|
1126
1136
|
exitCode: c.exitCode,
|
|
1127
1137
|
exitReason: c.exitReason,
|
|
1128
1138
|
eventCount: c.events.length,
|
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
AgentFramework,
|
|
34
34
|
McplServerConfig,
|
|
35
35
|
} from '@animalabs/agent-framework';
|
|
36
|
+
import { resolveTimeZone } from '@animalabs/agent-framework';
|
|
36
37
|
import {
|
|
37
38
|
DEFAULT_CONFIG_PATH,
|
|
38
39
|
DEFAULT_AGENT_OVERLAY_PATH,
|
|
@@ -44,6 +45,8 @@ import {
|
|
|
44
45
|
} from '../mcpl-config.js';
|
|
45
46
|
|
|
46
47
|
export interface McplAdminModuleConfig {
|
|
48
|
+
/** IANA zone propagated to newly deployed stdio servers. */
|
|
49
|
+
timeZone?: string;
|
|
47
50
|
/** Path to the agent overlay file. Default: `mcpl-servers.agent.json` in cwd. */
|
|
48
51
|
overlayPath?: string;
|
|
49
52
|
/** Path to the human-owned server config file (read-only here). */
|
|
@@ -64,10 +67,12 @@ export class McplAdminModule implements Module {
|
|
|
64
67
|
private framework: AgentFramework | null = null;
|
|
65
68
|
private overlayPath: string;
|
|
66
69
|
private configPath: string;
|
|
70
|
+
private timeZone: string;
|
|
67
71
|
|
|
68
72
|
constructor(config?: McplAdminModuleConfig) {
|
|
69
73
|
this.overlayPath = config?.overlayPath ?? DEFAULT_AGENT_OVERLAY_PATH;
|
|
70
74
|
this.configPath = config?.configPath ?? DEFAULT_CONFIG_PATH;
|
|
75
|
+
this.timeZone = resolveTimeZone(config?.timeZone);
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
/** Post-creation wiring (called from index.ts, mirrors ActivityModule.setFramework). */
|
|
@@ -275,6 +280,7 @@ export class McplAdminModule implements Module {
|
|
|
275
280
|
saveAgentOverlay(this.overlayPath, overlay);
|
|
276
281
|
|
|
277
282
|
const config = resolveOverlayEntry(id, entry, this.overlayPath) as unknown as McplServerConfig;
|
|
283
|
+
config.env = { ...(config.env ?? {}), AGENT_TIMEZONE: this.timeZone };
|
|
278
284
|
|
|
279
285
|
const alreadyLoaded = framework.listMcplServers().some(s => s.id === id);
|
|
280
286
|
try {
|
|
@@ -15,21 +15,23 @@ import type {
|
|
|
15
15
|
ToolCall,
|
|
16
16
|
ToolResult,
|
|
17
17
|
} from '@animalabs/agent-framework';
|
|
18
|
+
import { formatZonedDateTime, resolveTimeZone } from '@animalabs/agent-framework';
|
|
18
19
|
|
|
19
20
|
interface TimeState {
|
|
20
21
|
sessionStartAnnounced?: boolean;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
function formatNow(date: Date = new Date()): {
|
|
24
|
+
export function formatNow(date: Date = new Date(), configuredTimeZone?: string): {
|
|
24
25
|
iso: string;
|
|
25
26
|
local: string;
|
|
26
27
|
timezone: string;
|
|
27
28
|
unixMs: number;
|
|
28
29
|
} {
|
|
29
|
-
const timezone =
|
|
30
|
+
const timezone = resolveTimeZone(configuredTimeZone);
|
|
31
|
+
const local = formatZonedDateTime(date, timezone);
|
|
30
32
|
return {
|
|
31
|
-
iso:
|
|
32
|
-
local
|
|
33
|
+
iso: local.slice(0, local.indexOf(' [')),
|
|
34
|
+
local,
|
|
33
35
|
timezone,
|
|
34
36
|
unixMs: date.getTime(),
|
|
35
37
|
};
|
|
@@ -39,6 +41,11 @@ export class TimeModule implements Module {
|
|
|
39
41
|
readonly name = 'time';
|
|
40
42
|
|
|
41
43
|
private ctx: ModuleContext | null = null;
|
|
44
|
+
private readonly timeZone: string;
|
|
45
|
+
|
|
46
|
+
constructor(timeZone?: string) {
|
|
47
|
+
this.timeZone = resolveTimeZone(timeZone);
|
|
48
|
+
}
|
|
42
49
|
|
|
43
50
|
async start(ctx: ModuleContext): Promise<void> {
|
|
44
51
|
this.ctx = ctx;
|
|
@@ -46,10 +53,9 @@ export class TimeModule implements Module {
|
|
|
46
53
|
const state = ctx.getState<TimeState>() ?? {};
|
|
47
54
|
if (state.sessionStartAnnounced) return;
|
|
48
55
|
|
|
49
|
-
const now = formatNow();
|
|
56
|
+
const now = formatNow(new Date(), this.timeZone);
|
|
50
57
|
const text =
|
|
51
|
-
`The time at the start of this session is
|
|
52
|
-
`(local: ${now.local}, timezone: ${now.timezone}).`;
|
|
58
|
+
`The local time at the start of this session is ${now.local}.`;
|
|
53
59
|
|
|
54
60
|
ctx.addMessage('user', [{ type: 'text', text }]);
|
|
55
61
|
ctx.setState<TimeState>({ ...state, sessionStartAnnounced: true });
|
|
@@ -67,7 +73,7 @@ export class TimeModule implements Module {
|
|
|
67
73
|
return [
|
|
68
74
|
{
|
|
69
75
|
name: 'now',
|
|
70
|
-
description: 'Return the current wall-clock time
|
|
76
|
+
description: 'Return the current wall-clock time in the agent-configured timezone, plus unix milliseconds.',
|
|
71
77
|
inputSchema: {
|
|
72
78
|
type: 'object',
|
|
73
79
|
properties: {},
|
|
@@ -78,7 +84,7 @@ export class TimeModule implements Module {
|
|
|
78
84
|
|
|
79
85
|
async handleToolCall(call: ToolCall): Promise<ToolResult> {
|
|
80
86
|
if (call.name === 'now') {
|
|
81
|
-
return { success: true, data: formatNow() };
|
|
87
|
+
return { success: true, data: formatNow(new Date(), this.timeZone) };
|
|
82
88
|
}
|
|
83
89
|
return { success: false, isError: true, error: `Unknown tool: ${call.name}` };
|
|
84
90
|
}
|
|
@@ -41,6 +41,7 @@ import { createHash, timingSafeEqual } from 'node:crypto';
|
|
|
41
41
|
import type { Recipe } from '../recipe.js';
|
|
42
42
|
import type { SessionManager } from '../session-manager.js';
|
|
43
43
|
import type { BranchState } from '../commands.js';
|
|
44
|
+
import type { CallLedger } from '../call-ledger.js';
|
|
44
45
|
import { handleCommand } from '../commands.js';
|
|
45
46
|
import { AgentTreeReducer, type AgentTreeSnapshot } from '../state/agent-tree-reducer.js';
|
|
46
47
|
import { FleetTreeAggregator } from '../state/fleet-tree-aggregator.js';
|
|
@@ -54,6 +55,7 @@ import {
|
|
|
54
55
|
type WelcomeMessageEntry,
|
|
55
56
|
type RequestHistoryMessage,
|
|
56
57
|
type TokenUsage,
|
|
58
|
+
type CallLedgerSnapshot,
|
|
57
59
|
type McplListMessage,
|
|
58
60
|
type LessonsListMessage,
|
|
59
61
|
} from '../web/protocol.js';
|
|
@@ -128,6 +130,8 @@ export interface WebUiModuleConfig {
|
|
|
128
130
|
* scope mask. With no grants the webui behaves exactly as before.
|
|
129
131
|
*/
|
|
130
132
|
observersPath?: string;
|
|
133
|
+
/** Content-free recent provider-call ledger for spend/cache diagnostics. */
|
|
134
|
+
callLedger?: CallLedger;
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
/** Data stashed on the Bun WS upgrade. */
|
|
@@ -389,6 +393,9 @@ interface SharedServerState {
|
|
|
389
393
|
* every usage:updated event so the welcome and live UsageMessage frames
|
|
390
394
|
* carry consistent values. */
|
|
391
395
|
latestPerAgentCost: import('../web/protocol.js').PerAgentCost[];
|
|
396
|
+
/** Recent per-call cache diagnostics, updated directly by the adapter. */
|
|
397
|
+
latestCallLedger?: CallLedgerSnapshot;
|
|
398
|
+
callLedgerDetacher: (() => void) | null;
|
|
392
399
|
/** Currently-bound app, refreshed on every setApp() call. WS handlers read
|
|
393
400
|
* from here so the singleton always points at the live framework regardless
|
|
394
401
|
* of which WebUiModule instance is "active". */
|
|
@@ -464,6 +471,8 @@ export class WebUiModule implements Module {
|
|
|
464
471
|
parentUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
465
472
|
childUsage: new Map(),
|
|
466
473
|
latestPerAgentCost: [],
|
|
474
|
+
latestCallLedger: this.config.callLedger?.snapshot(),
|
|
475
|
+
callLedgerDetacher: null,
|
|
467
476
|
pendingFleetRequests: new Map(),
|
|
468
477
|
childRecipeCache: new Map(),
|
|
469
478
|
app: null,
|
|
@@ -495,6 +504,19 @@ export class WebUiModule implements Module {
|
|
|
495
504
|
}
|
|
496
505
|
sharedServer = state;
|
|
497
506
|
|
|
507
|
+
// Provider calls include auxiliary compression requests that never emit a
|
|
508
|
+
// framework usage trace, so subscribe at the adapter ledger itself. This
|
|
509
|
+
// keeps the panel live for both conversational and memory-maintenance
|
|
510
|
+
// calls without polling the JSONL log.
|
|
511
|
+
state.callLedgerDetacher = this.config.callLedger?.onUpdate((ledger) => {
|
|
512
|
+
state.latestCallLedger = ledger;
|
|
513
|
+
const msg: WebUiServerMessage = { type: 'call-ledger', ledger };
|
|
514
|
+
for (const client of state.clients.values()) {
|
|
515
|
+
if (!client.welcomed) continue;
|
|
516
|
+
if (client.scopes === null || client.scopes.has('health')) this.send(client, msg);
|
|
517
|
+
}
|
|
518
|
+
}) ?? null;
|
|
519
|
+
|
|
498
520
|
console.log(`[webui] listening on http://${host}:${boundPort}`);
|
|
499
521
|
}
|
|
500
522
|
|
|
@@ -2486,6 +2508,9 @@ export class WebUiModule implements Module {
|
|
|
2486
2508
|
...(sharedServer!.latestPerAgentCost.length > 0
|
|
2487
2509
|
? { perAgentCost: sharedServer!.latestPerAgentCost }
|
|
2488
2510
|
: {}),
|
|
2511
|
+
...(sharedServer!.latestCallLedger
|
|
2512
|
+
? { callLedger: sharedServer!.latestCallLedger }
|
|
2513
|
+
: {}),
|
|
2489
2514
|
};
|
|
2490
2515
|
}
|
|
2491
2516
|
|
|
@@ -283,12 +283,25 @@ export function filterEntryForScopes(
|
|
|
283
283
|
/** Project a welcome through an observer's scope mask. Without 'messages'
|
|
284
284
|
* the entire conversation payload (entries + agent trees, which carry
|
|
285
285
|
* streaming buffers) is emptied — a health/ops observer like the fleet hub
|
|
286
|
-
* gets structure and usage, never content.
|
|
286
|
+
* gets structure and usage, never content. Without 'health' the telemetry
|
|
287
|
+
* fields (usage / perAgentCost / callLedger) are masked too: their live
|
|
288
|
+
* frames (`usage`, `call-ledger`) are gated on 'health', and the welcome
|
|
289
|
+
* must refuse exactly what the stream refuses — otherwise a messages-only
|
|
290
|
+
* observer reads model IDs, per-call costs, and raw provider errors on
|
|
291
|
+
* connect that it is denied a second later. */
|
|
287
292
|
export function scopeWelcome(welcome: WelcomeMessage, scopes: Set<ObserverScope>): WelcomeMessage {
|
|
288
293
|
const emptyTree = { asOfTs: Date.now(), nodes: [], callIdIndex: {} };
|
|
294
|
+
const scoped: WelcomeMessage = { ...welcome };
|
|
295
|
+
if (!scopes.has('health')) {
|
|
296
|
+
delete scoped.callLedger;
|
|
297
|
+
delete scoped.perAgentCost;
|
|
298
|
+
// `usage` is required by the wire type, so it is zeroed rather than
|
|
299
|
+
// dropped (matches the emptied-not-deleted style of the fields below).
|
|
300
|
+
scoped.usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
301
|
+
}
|
|
289
302
|
if (!scopes.has('messages')) {
|
|
290
303
|
return {
|
|
291
|
-
...
|
|
304
|
+
...scoped,
|
|
292
305
|
messages: [],
|
|
293
306
|
history: { startIndex: welcome.history.totalCount, totalCount: welcome.history.totalCount },
|
|
294
307
|
localTree: emptyTree,
|
|
@@ -296,7 +309,7 @@ export function scopeWelcome(welcome: WelcomeMessage, scopes: Set<ObserverScope>
|
|
|
296
309
|
};
|
|
297
310
|
}
|
|
298
311
|
return {
|
|
299
|
-
...
|
|
312
|
+
...scoped,
|
|
300
313
|
messages: welcome.messages
|
|
301
314
|
.map((e) => filterEntryForScopes(e, scopes))
|
|
302
315
|
.filter((e): e is WelcomeMessageEntry => e !== null),
|