@animalabs/connectome-host 0.3.1 → 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/.github/workflows/publish.yml +9 -4
- package/README.md +5 -0
- package/bun.lock +8 -4
- package/docs/AGENT-ONBOARDING.md +6 -1
- package/package.json +5 -5
- package/scripts/import-codex-rollout.ts +288 -0
- package/src/call-ledger.ts +371 -0
- package/src/call-pricing.ts +119 -0
- package/src/commands.ts +57 -3
- package/src/index.ts +87 -27
- package/src/logging-adapter.ts +72 -9
- package/src/modules/fleet-module.ts +21 -9
- package/src/modules/mcpl-admin-module.ts +6 -0
- package/src/modules/observers-module.ts +180 -0
- package/src/modules/time-module.ts +15 -9
- package/src/modules/web-ui-module.ts +415 -16
- package/src/modules/web-ui-observers.ts +322 -0
- package/src/recipe.ts +48 -3
- package/src/strategies/frontdesk-strategy.ts +10 -4
- package/src/web/protocol.ts +141 -0
- package/test/call-ledger.test.ts +91 -0
- package/test/call-pricing.test.ts +69 -0
- package/test/fleet-subscribe-union-e2e.test.ts +5 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +2 -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-context-coverage.test.ts +61 -0
- package/test/web-ui-observers.test.ts +344 -0
- package/web/package-lock.json +2446 -0
- package/web/src/App.tsx +24 -0
- package/web/src/Context.tsx +207 -7
- package/web/src/ObserverGate.tsx +78 -0
- package/web/src/Usage.tsx +116 -2
- package/web/src/observer-identity.ts +110 -0
- package/web/src/wire.ts +60 -1
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';
|
|
@@ -34,6 +40,7 @@ import { ActivityModule } from './modules/activity-module.js';
|
|
|
34
40
|
import { SubscriptionGcModule } from './modules/subscription-gc-module.js';
|
|
35
41
|
import { ChannelModeModule } from './modules/channel-mode-module.js';
|
|
36
42
|
import { WebUiModule } from './modules/web-ui-module.js';
|
|
43
|
+
import { ObserversModule } from './modules/observers-module.js';
|
|
37
44
|
import { McplAdminModule } from './modules/mcpl-admin-module.js';
|
|
38
45
|
import { loadMcplServers, applyAgentOverlay, DEFAULT_CONFIG_PATH, DEFAULT_AGENT_OVERLAY_PATH } from './mcpl-config.js';
|
|
39
46
|
import { SessionManager } from './session-manager.js';
|
|
@@ -60,15 +67,11 @@ const config = {
|
|
|
60
67
|
// OAuth/Bearer token (e.g. a Claude subscription token). When set, it takes
|
|
61
68
|
// precedence over the API key so requests never carry both auth schemes.
|
|
62
69
|
authToken: process.env.ANTHROPIC_AUTH_TOKEN,
|
|
70
|
+
openaiApiKey: process.env.OPENAI_API_KEY,
|
|
63
71
|
model: process.env.MODEL,
|
|
64
72
|
dataDir: process.env.DATA_DIR || './data',
|
|
65
73
|
};
|
|
66
74
|
|
|
67
|
-
if (!config.apiKey && !config.authToken) {
|
|
68
|
-
console.error('Missing ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN). Set one in .env or environment.');
|
|
69
|
-
process.exit(1);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
75
|
// ---------------------------------------------------------------------------
|
|
73
76
|
// AppContext — mutable container for session switching
|
|
74
77
|
// ---------------------------------------------------------------------------
|
|
@@ -137,14 +140,16 @@ async function createFramework(
|
|
|
137
140
|
recipe: Recipe,
|
|
138
141
|
agentName: string,
|
|
139
142
|
settingsModule: SettingsModule,
|
|
143
|
+
callLedger: CallLedger | null,
|
|
140
144
|
): Promise<AgentFramework> {
|
|
141
145
|
const model = config.model || recipe.agent.model || 'claude-opus-4-6';
|
|
142
146
|
const modules = recipe.modules ?? {};
|
|
147
|
+
const timeZone = resolveTimeZone(recipe.agent.timezone);
|
|
143
148
|
|
|
144
149
|
// -- Build module list --
|
|
145
150
|
// SettingsModule is constructed in main() (before the adapter, so the
|
|
146
151
|
// adapter can read its state for cross-cutting concerns like reasoning).
|
|
147
|
-
const moduleInstances: Module[] = [new TuiModule(), new TimeModule(), settingsModule];
|
|
152
|
+
const moduleInstances: Module[] = [new TuiModule(), new TimeModule(timeZone), settingsModule];
|
|
148
153
|
|
|
149
154
|
// Subagents
|
|
150
155
|
let subagentModule: SubagentModule | null = null;
|
|
@@ -168,10 +173,11 @@ async function createFramework(
|
|
|
168
173
|
|
|
169
174
|
// Fleet (cross-process child orchestration). Opt-in via recipe.
|
|
170
175
|
if (modules.fleet === true) {
|
|
171
|
-
moduleInstances.push(new FleetModule());
|
|
176
|
+
moduleInstances.push(new FleetModule({ timeZone }));
|
|
172
177
|
} else if (modules.fleet && typeof modules.fleet === 'object') {
|
|
173
178
|
const fleetCfg = modules.fleet;
|
|
174
179
|
const fleetModuleConfig: FleetModuleConfig = {};
|
|
180
|
+
fleetModuleConfig.timeZone = timeZone;
|
|
175
181
|
if (fleetCfg.children) {
|
|
176
182
|
fleetModuleConfig.autoStart = fleetCfg.children.map((c) => {
|
|
177
183
|
const entry: NonNullable<FleetModuleConfig['autoStart']>[number] = {
|
|
@@ -309,7 +315,7 @@ async function createFramework(
|
|
|
309
315
|
// ability to spawn arbitrary commands via mcpl_deploy; see recipe.ts).
|
|
310
316
|
let mcplAdminModule: McplAdminModule | null = null;
|
|
311
317
|
if (modules.mcplAdmin === true) {
|
|
312
|
-
mcplAdminModule = new McplAdminModule();
|
|
318
|
+
mcplAdminModule = new McplAdminModule({ timeZone });
|
|
313
319
|
moduleInstances.push(mcplAdminModule);
|
|
314
320
|
}
|
|
315
321
|
|
|
@@ -317,13 +323,22 @@ async function createFramework(
|
|
|
317
323
|
let webUiModule: WebUiModule | null = null;
|
|
318
324
|
if (modules.webui !== undefined && modules.webui !== false) {
|
|
319
325
|
const webuiConfig = typeof modules.webui === 'object' ? modules.webui : {};
|
|
326
|
+
// Observer grants (docs/observability.md): data/observers.json by
|
|
327
|
+
// default, overridable via OBSERVERS_FILE. The feature is inert until
|
|
328
|
+
// the file holds at least one grant. The companion ObserversModule
|
|
329
|
+
// gives the agent grant/revoke tools over the same file — interiority
|
|
330
|
+
// access is the agent's to give.
|
|
331
|
+
const observersPath = process.env.OBSERVERS_FILE || resolve(config.dataDir, 'observers.json');
|
|
320
332
|
webUiModule = new WebUiModule({
|
|
321
333
|
port: webuiConfig.port,
|
|
322
334
|
host: webuiConfig.host,
|
|
323
335
|
basicAuth: webuiConfig.basicAuth,
|
|
324
336
|
allowedOrigins: webuiConfig.allowedOrigins,
|
|
337
|
+
observersPath,
|
|
338
|
+
...(callLedger ? { callLedger } : {}),
|
|
325
339
|
});
|
|
326
340
|
moduleInstances.push(webUiModule);
|
|
341
|
+
moduleInstances.push(new ObserversModule({ path: observersPath }));
|
|
327
342
|
}
|
|
328
343
|
|
|
329
344
|
// -- Build MCP server list --
|
|
@@ -375,7 +390,12 @@ async function createFramework(
|
|
|
375
390
|
// Apply the agent overlay (mcpl-servers.agent.json): servers the agent
|
|
376
391
|
// deployed for itself load unconditionally (no recipe opt-in), and
|
|
377
392
|
// tombstones suppress recipe/file servers the agent unloaded.
|
|
378
|
-
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
|
+
}));
|
|
379
399
|
|
|
380
400
|
// No server augmentation needed — gate is wired via FrameworkConfig.gate
|
|
381
401
|
|
|
@@ -396,6 +416,7 @@ async function createFramework(
|
|
|
396
416
|
compressionModel: strategyConfig?.compressionModel ?? model,
|
|
397
417
|
autoTickOnNewMessage: true,
|
|
398
418
|
maxMessageTokens: strategyConfig?.maxMessageTokens ?? 10000,
|
|
419
|
+
...(strategyType === 'frontdesk' ? { timeZone } : {}),
|
|
399
420
|
};
|
|
400
421
|
// Forward optional tuning fields when set. The key list is typed
|
|
401
422
|
// against `RecipeStrategy`, so an unknown field name is a compile
|
|
@@ -456,6 +477,20 @@ async function createFramework(
|
|
|
456
477
|
maxStreamTokens: recipe.agent.maxStreamTokens ?? 150000,
|
|
457
478
|
contextBudgetTokens: recipe.agent.contextBudgetTokens,
|
|
458
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
|
+
}),
|
|
459
494
|
strategy,
|
|
460
495
|
...(recipe.agent.thinking && { thinking: recipe.agent.thinking }),
|
|
461
496
|
...(recipe.agent.refusalHandling && { refusalHandling: recipe.agent.refusalHandling }),
|
|
@@ -464,6 +499,7 @@ async function createFramework(
|
|
|
464
499
|
modules: moduleInstances,
|
|
465
500
|
mcplServers: finalServers,
|
|
466
501
|
gate: gateOptions,
|
|
502
|
+
timeZone,
|
|
467
503
|
});
|
|
468
504
|
|
|
469
505
|
// Wire post-creation hooks
|
|
@@ -743,6 +779,16 @@ function countLines(path: string): number {
|
|
|
743
779
|
|
|
744
780
|
async function main() {
|
|
745
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
|
+
}
|
|
746
792
|
|
|
747
793
|
// SettingsModule constructed early so the adapter can read its state for
|
|
748
794
|
// cross-cutting concerns (currently: reasoning). It's wired into the
|
|
@@ -757,22 +803,34 @@ async function main() {
|
|
|
757
803
|
config.dataDir,
|
|
758
804
|
`llm-calls.${new Date().toISOString().replace(/[:.]/g, '-')}.jsonl`,
|
|
759
805
|
);
|
|
806
|
+
const callLedger = provider === 'anthropic'
|
|
807
|
+
? new CallLedger({
|
|
808
|
+
dataDir: config.dataDir,
|
|
809
|
+
defaultTtl: recipe.agent.cacheTtl ?? '5m',
|
|
810
|
+
})
|
|
811
|
+
: null;
|
|
760
812
|
// OAuth (subscription) auth wins over API-key auth when both are present.
|
|
761
813
|
// Subscription tokens (sk-ant-oat…) additionally require the oauth beta
|
|
762
814
|
// header on every request.
|
|
763
|
-
const adapter =
|
|
764
|
-
{
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
+
);
|
|
776
834
|
|
|
777
835
|
// Session management — resolved before Membrane construction so the
|
|
778
836
|
// active session's import-source sidecar can contribute to agent-name
|
|
@@ -805,7 +863,9 @@ async function main() {
|
|
|
805
863
|
const agentName = resolved.name;
|
|
806
864
|
|
|
807
865
|
const membrane = new Membrane(adapter, {
|
|
808
|
-
formatter:
|
|
866
|
+
formatter: provider === 'openai-responses'
|
|
867
|
+
? new OpenAIResponsesFormatter()
|
|
868
|
+
: new NativeFormatter(),
|
|
809
869
|
// Anchor the assistant role for internal callers that don't set
|
|
810
870
|
// request.assistantParticipant themselves (autobio compression,
|
|
811
871
|
// executeMerge). Mismatch here flips stored assistant turns to
|
|
@@ -814,7 +874,7 @@ async function main() {
|
|
|
814
874
|
});
|
|
815
875
|
|
|
816
876
|
const storePath = sessionManager.getStorePath(activeSession.id);
|
|
817
|
-
const framework = await createFramework(membrane, storePath, recipe, agentName, settingsModule);
|
|
877
|
+
const framework = await createFramework(membrane, storePath, recipe, agentName, settingsModule, callLedger);
|
|
818
878
|
|
|
819
879
|
// Build app context
|
|
820
880
|
const app: AppContext = {
|
|
@@ -835,7 +895,7 @@ async function main() {
|
|
|
835
895
|
// re-resolution would matter only if recipe.agent.name is absent
|
|
836
896
|
// AND the user switches between imports that used different
|
|
837
897
|
// --agent values; not the canonical flow.
|
|
838
|
-
this.framework = await createFramework(membrane, newStorePath, recipe, this.agentName, settingsModule);
|
|
898
|
+
this.framework = await createFramework(membrane, newStorePath, recipe, this.agentName, settingsModule, callLedger);
|
|
839
899
|
this.framework.start();
|
|
840
900
|
this.userMessageCount = 0;
|
|
841
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,9 +29,10 @@ 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
|
-
import { existsSync, mkdirSync, unlinkSync, openSync, closeSync, appendFileSync } from 'node:fs';
|
|
35
|
+
import { existsSync, mkdirSync, unlinkSync, openSync, closeSync, appendFileSync, realpathSync } from 'node:fs';
|
|
35
36
|
import { join, resolve, isAbsolute } from 'node:path';
|
|
36
37
|
import { type IncomingCommand, type WireEvent, matchesSubscription } from './fleet-types.js';
|
|
37
38
|
import { loadRecipe } from '../recipe.js';
|
|
@@ -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,
|
|
@@ -1202,9 +1212,11 @@ export class FleetModule implements Module {
|
|
|
1202
1212
|
* entries derived from `children[]`, which are absolute post-load).
|
|
1203
1213
|
*/
|
|
1204
1214
|
private matchesAllowlist(recipe: string, resolved: string): boolean {
|
|
1215
|
+
const canonicalResolved = existsSync(resolved) ? realpathSync(resolved) : resolved;
|
|
1205
1216
|
for (const entry of this.allowlist) {
|
|
1206
1217
|
if (entry === '*') return true;
|
|
1207
|
-
|
|
1218
|
+
const canonicalEntry = !entry.includes('*') && existsSync(entry) ? realpathSync(entry) : entry;
|
|
1219
|
+
if (entry === recipe || entry === resolved || canonicalEntry === canonicalResolved) return true;
|
|
1208
1220
|
if (entry.endsWith('*') && !entry.slice(0, -1).includes('*')) {
|
|
1209
1221
|
const prefix = entry.slice(0, -1);
|
|
1210
1222
|
if (recipe.startsWith(prefix) || resolved.startsWith(prefix)) return true;
|
|
@@ -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 {
|