@animalabs/connectome-host 0.7.4 → 0.8.1
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 +12 -5
- package/.github/PULL_REQUEST_TEMPLATE.md +3 -2
- package/.github/workflows/changelog.yml +9 -4
- package/.github/workflows/ci.yml +5 -3
- package/.github/workflows/publish.yml +12 -6
- package/CHANGELOG.md +320 -0
- package/CONTRIBUTING.md +47 -19
- package/README.md +27 -0
- package/bun.lock +26 -32
- package/changelog.d/README.md +28 -0
- package/package.json +6 -6
- package/recipes/SETUP.md +11 -5
- package/recipes/TRIUMVIRATE-SETUP.md +68 -14
- package/recipes/knowledge-miner.json +0 -30
- package/recipes/mock-test.json +19 -0
- package/recipes/triumvirate.json +6 -1
- package/scripts/release-changelog.ts +210 -21
- package/src/cache-keepalive-log.ts +41 -0
- package/src/commands.ts +221 -32
- package/src/framework-agent-config.ts +3 -0
- package/src/framework-strategy.ts +42 -0
- package/src/gate-telemetry.ts +134 -0
- package/src/headless.ts +10 -0
- package/src/index.ts +194 -55
- package/src/mcpl-config.ts +99 -1
- package/src/modules/identity-module.ts +310 -2
- package/src/modules/instructions-module.ts +265 -0
- package/src/modules/mcpl-admin-module.ts +58 -11
- package/src/modules/subagent-module.ts +18 -0
- package/src/modules/web-ui-module.ts +32 -4
- package/src/recipe.ts +821 -25
- package/src/web/panel-data.ts +44 -1
- package/src/workspace-mounts.ts +73 -0
- package/test/audit-module-optins.test.ts +10 -3
- package/test/cache-keepalive-log.test.ts +83 -0
- package/test/commands-qa-family.test.ts +239 -0
- package/test/conversations-recipe.test.ts +142 -0
- package/test/count-tokens-model.test.ts +31 -0
- package/test/framework-fkm-composition.test.ts +35 -3
- package/test/framework-strategy-defaults.test.ts +60 -0
- package/test/gate-telemetry-adapter.test.ts +84 -0
- package/test/gate-telemetry.test.ts +124 -0
- package/test/identity-and-surfaces.test.ts +212 -1
- package/test/instructions-module.test.ts +258 -0
- package/test/mcpl-admin-module.test.ts +41 -0
- package/test/mcpl-agent-overlay.test.ts +51 -3
- package/test/mcpl-child-env.test.ts +64 -0
- package/test/nudge-command.test.ts +47 -0
- package/test/recipe-cache-keepalive.test.ts +59 -0
- package/test/recipe-compression-fallback.test.ts +19 -0
- package/test/recipe-hybrid-prose-routing.test.ts +12 -0
- package/test/recipe-instructions.test.ts +176 -0
- package/test/recipe-kv-unified.test.ts +87 -0
- package/test/recipe-mcp-source.test.ts +54 -0
- package/test/recipe-openai-compatible.test.ts +54 -0
- package/test/recipe-path-resolution.test.ts +19 -8
- package/test/recipe-provider.test.ts +14 -0
- package/test/recipe-save-unresolved.test.ts +244 -0
- package/test/recipe-source-only.test.ts +38 -0
- package/test/release-changelog.test.ts +202 -0
- package/test/subagent-prose-routing.test.ts +109 -0
- package/test/subconscious-recipe.test.ts +86 -0
- package/test/tool-wrapper-prose-guard-recipe.test.ts +37 -0
- package/test/web-ui-module.test.ts +41 -0
- package/test/workspace-mounts.test.ts +68 -0
- package/web/src/App.tsx +10 -0
- package/web/src/Health.tsx +61 -1
package/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* bun src/index.ts --headless --exit-when-idle # One-shot: exit when agents go idle after first inference
|
|
11
11
|
*
|
|
12
12
|
* Environment variables:
|
|
13
|
-
* ANTHROPIC_API_KEY - Required
|
|
13
|
+
* ANTHROPIC_API_KEY - Required (not needed for recipe provider "mock")
|
|
14
14
|
* MODEL - Override model (default: from recipe or claude-opus-4-6)
|
|
15
15
|
* DATA_DIR - Data directory for sessions (default: ./data)
|
|
16
16
|
*/
|
|
@@ -19,18 +19,21 @@ import {
|
|
|
19
19
|
AnthropicXmlFormatter,
|
|
20
20
|
BedrockAdapter,
|
|
21
21
|
Membrane,
|
|
22
|
+
MockAdapter,
|
|
22
23
|
NativeFormatter,
|
|
23
24
|
OpenAIResponsesAPIAdapter,
|
|
24
25
|
OpenAIResponsesFormatter,
|
|
26
|
+
OpenAICompatibleAdapter,
|
|
25
27
|
OpenRouterAdapter,
|
|
26
28
|
} from '@animalabs/membrane';
|
|
27
29
|
import { LoggingAnthropicAdapter } from './logging-adapter.js';
|
|
28
30
|
import { LoggingProviderAdapter } from './logging-provider-wrapper.js';
|
|
31
|
+
import { gateTelemetryHeaders, stampedTrigger, type TurnTrigger } from './gate-telemetry.js';
|
|
29
32
|
import { LoggingBedrockAdapter } from './logging-bedrock-adapter.js';
|
|
30
33
|
import { CodexSubscriptionAdapter } from './codex-subscription-adapter.js';
|
|
31
34
|
import { CallLedger } from './call-ledger.js';
|
|
32
35
|
import { SettingsModule } from './modules/settings-module.js';
|
|
33
|
-
import { AgentFramework, WorkspaceModule, resolveTimeZone, type Module
|
|
36
|
+
import { AgentFramework, WorkspaceModule, resolveTimeZone, type Module } from '@animalabs/agent-framework';
|
|
34
37
|
import { resolve, join, basename } from 'node:path';
|
|
35
38
|
import { appendFile, mkdir, stat, rename } from 'node:fs/promises';
|
|
36
39
|
import { readFileSync, existsSync } from 'node:fs';
|
|
@@ -38,7 +41,6 @@ import { SubagentModule } from './modules/subagent-module.js';
|
|
|
38
41
|
import { LessonsModule } from './modules/lessons-module.js';
|
|
39
42
|
import { RetrievalModule } from './modules/retrieval-module.js';
|
|
40
43
|
import { buildRetrievalModuleConfig } from './retrieval-config.js';
|
|
41
|
-
import type { RecipeWorkspaceMount } from './recipe.js';
|
|
42
44
|
import { TuiModule } from './modules/tui-module.js';
|
|
43
45
|
import { TimeModule } from './modules/time-module.js';
|
|
44
46
|
import { FleetModule, type FleetModuleConfig } from './modules/fleet-module.js';
|
|
@@ -50,14 +52,15 @@ import { ObserversModule } from './modules/observers-module.js';
|
|
|
50
52
|
import { IdentityModule } from './modules/identity-module.js';
|
|
51
53
|
import { McplAdminModule } from './modules/mcpl-admin-module.js';
|
|
52
54
|
import { TtsRelayModule } from './modules/tts-relay-module.js';
|
|
53
|
-
import {
|
|
55
|
+
import { InstructionsModule } from './modules/instructions-module.js';
|
|
56
|
+
import { loadMcplServers, applyAgentOverlay, composeMcplChildEnv, DEFAULT_CONFIG_PATH, DEFAULT_AGENT_OVERLAY_PATH } from './mcpl-config.js';
|
|
54
57
|
import { SessionManager } from './session-manager.js';
|
|
55
58
|
import { resolveAgentName } from './agent-name.js';
|
|
56
59
|
import { generateSessionName } from './synesthete.js';
|
|
57
60
|
import {
|
|
58
61
|
type Recipe,
|
|
59
62
|
DEFAULT_RECIPE,
|
|
60
|
-
|
|
63
|
+
loadRecipeDetailed,
|
|
61
64
|
saveRecipe,
|
|
62
65
|
loadSavedRecipe,
|
|
63
66
|
clearSavedRecipe,
|
|
@@ -65,7 +68,9 @@ import {
|
|
|
65
68
|
} from './recipe.js';
|
|
66
69
|
import { createBranchState, resetBranchState, handleExport, type BranchState } from './commands.js';
|
|
67
70
|
import { buildFrameworkAgentConfig, membraneCachingOverride } from './framework-agent-config.js';
|
|
68
|
-
import { buildFrameworkStrategy } from './framework-strategy.js';
|
|
71
|
+
import { buildFrameworkStrategy, buildConversationsConfig } from './framework-strategy.js';
|
|
72
|
+
import { buildWorkspaceMounts } from './workspace-mounts.js';
|
|
73
|
+
import { logKeepaliveEvent } from './cache-keepalive-log.js';
|
|
69
74
|
import { loadExtensions } from './extensions.js';
|
|
70
75
|
|
|
71
76
|
export type { AppContext };
|
|
@@ -80,6 +85,10 @@ const config = {
|
|
|
80
85
|
authToken: process.env.ANTHROPIC_AUTH_TOKEN,
|
|
81
86
|
openaiApiKey: process.env.OPENAI_API_KEY,
|
|
82
87
|
openrouterApiKey: process.env.OPENROUTER_API_KEY,
|
|
88
|
+
// Deliberately NO fallback to OPENAI_API_KEY: agent.baseUrl is
|
|
89
|
+
// recipe-controlled, so a fallback would silently send a real OpenAI
|
|
90
|
+
// credential as a Bearer token to whatever endpoint a recipe names.
|
|
91
|
+
openaiCompatibleApiKey: process.env.OPENAI_COMPATIBLE_API_KEY,
|
|
83
92
|
codexBinary: process.env.CODEX_BINARY,
|
|
84
93
|
model: process.env.MODEL,
|
|
85
94
|
dataDir: process.env.DATA_DIR || './data',
|
|
@@ -128,8 +137,10 @@ async function resolveRecipe(): Promise<Recipe> {
|
|
|
128
137
|
|
|
129
138
|
if (source) {
|
|
130
139
|
try {
|
|
131
|
-
|
|
132
|
-
|
|
140
|
+
// Save the unresolved (pre-substitution) form: $DATA_DIR is typically
|
|
141
|
+
// host-mounted and backed up, so resolved secrets must never land there.
|
|
142
|
+
const { recipe, persistable } = await loadRecipeDetailed(source);
|
|
143
|
+
saveRecipe(config.dataDir, persistable);
|
|
133
144
|
console.log(`Loaded recipe: ${recipe.name}${recipe.description ? ` — ${recipe.description}` : ''}`);
|
|
134
145
|
return recipe;
|
|
135
146
|
} catch (err) {
|
|
@@ -138,11 +149,21 @@ async function resolveRecipe(): Promise<Recipe> {
|
|
|
138
149
|
}
|
|
139
150
|
}
|
|
140
151
|
|
|
141
|
-
// Try saved recipe
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
152
|
+
// Try saved recipe. Re-resolves ${VAR} references against the CURRENT
|
|
153
|
+
// environment, so a missing secret fails loudly here rather than starting
|
|
154
|
+
// a misconfigured agent on the default recipe.
|
|
155
|
+
try {
|
|
156
|
+
const saved = await loadSavedRecipe(config.dataDir);
|
|
157
|
+
if (saved) {
|
|
158
|
+
console.log(`Resuming recipe: ${saved.name}`);
|
|
159
|
+
return saved;
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error(
|
|
163
|
+
`Failed to resume saved recipe from ${config.dataDir}:`,
|
|
164
|
+
err instanceof Error ? err.message : err,
|
|
165
|
+
);
|
|
166
|
+
process.exit(1);
|
|
146
167
|
}
|
|
147
168
|
|
|
148
169
|
return DEFAULT_RECIPE;
|
|
@@ -260,48 +281,30 @@ async function createFramework(
|
|
|
260
281
|
// Note: workspace: false disables ALL filesystem access (both read and write).
|
|
261
282
|
// Previously LocalFilesModule was always-on; this is an intentional change —
|
|
262
283
|
// recipes that need read-only access should keep workspace enabled (the default).
|
|
284
|
+
// Mount construction lives in workspace-mounts.ts so recipe validation
|
|
285
|
+
// reasons over the SAME mount flags the runtime builds (see that file's
|
|
286
|
+
// header for why).
|
|
263
287
|
let workspaceModule: WorkspaceModule | null = null;
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
// Only pass fields the recipe explicitly provides; let WorkspaceModule default the rest.
|
|
268
|
-
// We override watch to 'never' since FKM doesn't need chokidar filesystem watchers.
|
|
269
|
-
mounts = modules.workspace.mounts.map((m: RecipeWorkspaceMount) => {
|
|
270
|
-
const mount: MountConfig = {
|
|
271
|
-
name: m.name,
|
|
272
|
-
path: resolve(m.path),
|
|
273
|
-
mode: m.mode ?? 'read-write',
|
|
274
|
-
watch: m.watch ?? 'never', // FKM: no chokidar watchers by default
|
|
275
|
-
};
|
|
276
|
-
if (m.ignore) mount.ignore = m.ignore;
|
|
277
|
-
if (m.wakeOnChange !== undefined) mount.wakeOnChange = m.wakeOnChange;
|
|
278
|
-
if (m.autoMaterialize !== undefined) mount.autoMaterialize = m.autoMaterialize;
|
|
279
|
-
return mount;
|
|
280
|
-
});
|
|
281
|
-
} else {
|
|
282
|
-
// Default: read-only input mount + read-write products mount
|
|
283
|
-
mounts = [
|
|
284
|
-
{ name: 'input', path: resolve('./input'), mode: 'read-only', watch: 'never' },
|
|
285
|
-
{ name: 'products', path: resolve('./output'), mode: 'read-write', watch: 'never' },
|
|
286
|
-
];
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Config mount: version-controls gate.json (and future config files) via Chronicle.
|
|
290
|
-
// Opt-in via recipe: workspace.configMount = true
|
|
291
|
-
const wantConfigMount = typeof modules.workspace === 'object' && modules.workspace.configMount;
|
|
292
|
-
if (wantConfigMount) {
|
|
293
|
-
mounts.push({
|
|
294
|
-
name: '_config',
|
|
295
|
-
path: resolve(join(storePath, 'config')),
|
|
296
|
-
mode: 'read-write',
|
|
297
|
-
watch: 'always',
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
workspaceModule = new WorkspaceModule({ mounts });
|
|
288
|
+
const workspaceMounts = buildWorkspaceMounts(modules.workspace, storePath);
|
|
289
|
+
if (workspaceMounts) {
|
|
290
|
+
workspaceModule = new WorkspaceModule({ mounts: workspaceMounts });
|
|
302
291
|
moduleInstances.push(workspaceModule);
|
|
303
292
|
}
|
|
304
293
|
|
|
294
|
+
// Shared operating instructions — opt-in per recipe. Injects a living
|
|
295
|
+
// instructions document (read through a workspace mount) into EVERY
|
|
296
|
+
// agent's context on every turn — resident and ephemeral subagents alike —
|
|
297
|
+
// via gatherContext. Requires the workspace module; validateRecipe rejects
|
|
298
|
+
// the instructions+`workspace: false` pairing, so workspaceModule is
|
|
299
|
+
// non-null here (the guard keeps the module fail-open regardless).
|
|
300
|
+
if (modules.instructions) {
|
|
301
|
+
const instructionsConfig =
|
|
302
|
+
typeof modules.instructions === 'object' ? modules.instructions : {};
|
|
303
|
+
const instructionsModule = new InstructionsModule(instructionsConfig);
|
|
304
|
+
if (workspaceModule) instructionsModule.setWorkspace(workspaceModule);
|
|
305
|
+
moduleInstances.push(instructionsModule);
|
|
306
|
+
}
|
|
307
|
+
|
|
305
308
|
// Activity (typing indicators) — opt-in per recipe
|
|
306
309
|
let activityModule: ActivityModule | null = null;
|
|
307
310
|
if (modules.activity !== undefined && modules.activity !== false) {
|
|
@@ -461,9 +464,11 @@ async function createFramework(
|
|
|
461
464
|
const finalServers = applyAgentOverlay(allServers, DEFAULT_AGENT_OVERLAY_PATH).map((server) => {
|
|
462
465
|
const withEnv: { id: string; command?: string; url?: string; [k: string]: unknown } = {
|
|
463
466
|
...server,
|
|
464
|
-
// Stdio MCPL children inherit a single agent-facing wall clock
|
|
465
|
-
//
|
|
466
|
-
|
|
467
|
+
// Stdio MCPL children inherit a single agent-facing wall clock plus the
|
|
468
|
+
// framework's refusal-annotation suppression baseline (operator env
|
|
469
|
+
// supersedes the baseline; see composeMcplChildEnv). Protocol
|
|
470
|
+
// timestamps remain UTC; only their rendered text uses AGENT_TIMEZONE.
|
|
471
|
+
env: composeMcplChildEnv(server.env as Record<string, string> | undefined, timeZone),
|
|
467
472
|
};
|
|
468
473
|
// `access` is a declarative name (recipe/file/overlay); the credential
|
|
469
474
|
// provider it implies is attached HERE, at load time — fresh credential
|
|
@@ -489,6 +494,10 @@ async function createFramework(
|
|
|
489
494
|
const strategy = buildFrameworkStrategy(recipe, model, timeZone, extensionRegistry);
|
|
490
495
|
const agentConfig = buildFrameworkAgentConfig(recipe, agentName, model, strategy);
|
|
491
496
|
|
|
497
|
+
// Per-channel conversation routing: the recipe agent becomes the trunk
|
|
498
|
+
// template; forks get a fresh instance of the same recipe strategy.
|
|
499
|
+
const conversations = buildConversationsConfig(recipe, agentName, model, timeZone, extensionRegistry);
|
|
500
|
+
|
|
492
501
|
// -- Create framework --
|
|
493
502
|
const framework = await AgentFramework.create({
|
|
494
503
|
storePath,
|
|
@@ -500,6 +509,10 @@ agents: [agentConfig],
|
|
|
500
509
|
timeZone,
|
|
501
510
|
// Client-side programmatic tool calling (code_execution) — recipe opt-in.
|
|
502
511
|
...(recipe.codeExecution ? { codeExecution: recipe.codeExecution } : {}),
|
|
512
|
+
...(conversations ? { conversations } : {}),
|
|
513
|
+
// Tune-out's subconscious resident (agent-framework#77) — recipe opt-in,
|
|
514
|
+
// passed through verbatim; the framework owns the defaults.
|
|
515
|
+
...(recipe.subconscious ? { subconscious: recipe.subconscious } : {}),
|
|
503
516
|
});
|
|
504
517
|
|
|
505
518
|
// Wire post-creation hooks
|
|
@@ -872,6 +885,16 @@ async function main() {
|
|
|
872
885
|
fastMode: recipe.agent.codex?.fastMode ?? false,
|
|
873
886
|
})
|
|
874
887
|
: undefined;
|
|
888
|
+
// Generic OpenAI-compatible chat-completions endpoint (Ollama, vLLM, Together,
|
|
889
|
+
// Groq, NanoGPT, ...). The recipe carries the endpoint (agent.baseUrl,
|
|
890
|
+
// validated at load); the key is optional because local servers have none.
|
|
891
|
+
const openaiCompatibleAdapter = provider === 'openai-compatible'
|
|
892
|
+
? new OpenAICompatibleAdapter({
|
|
893
|
+
baseURL: recipe.agent.baseUrl!,
|
|
894
|
+
apiKey: config.openaiCompatibleApiKey || undefined,
|
|
895
|
+
providerName: 'openai-compatible',
|
|
896
|
+
})
|
|
897
|
+
: undefined;
|
|
875
898
|
const openrouterAdapter = provider === 'openrouter'
|
|
876
899
|
? new OpenRouterAdapter({
|
|
877
900
|
apiKey: config.openrouterApiKey!,
|
|
@@ -890,9 +913,98 @@ async function main() {
|
|
|
890
913
|
// live probe 2026-07-31. Still no CallLedger — it's
|
|
891
914
|
// anthropic-transport-only for now; cache metrics are visible in
|
|
892
915
|
// llm-calls.jsonl via the logging wrapper.
|
|
916
|
+
// BEDROCK_BASE_URL routes bedrock-runtime calls through an inference
|
|
917
|
+
// gateway (gate.animalabs.ai/bedrock/<credSet>) — mirrors the
|
|
918
|
+
// ANTHROPIC_BASE_URL hook below. The gate reads the agent token from the
|
|
919
|
+
// SigV4 Credential (AWS_ACCESS_KEY_ID slot), discards the client
|
|
920
|
+
// signature, and re-signs with real AWS creds held on the gate box.
|
|
893
921
|
const bedrockAdapter = provider === 'bedrock'
|
|
894
|
-
? new LoggingBedrockAdapter(
|
|
922
|
+
? new LoggingBedrockAdapter(
|
|
923
|
+
{ baseURL: process.env.BEDROCK_BASE_URL || undefined },
|
|
924
|
+
llmLogPath,
|
|
925
|
+
)
|
|
926
|
+
: undefined;
|
|
927
|
+
// Mock: membrane's canned/echo adapter — the full host loop with zero
|
|
928
|
+
// provider spend and no credentials (none of the key checks above are
|
|
929
|
+
// gated on it). Echo is the default because it's the informative shape
|
|
930
|
+
// for interactive smoke runs; recipe agent.mock.echoMode=false switches
|
|
931
|
+
// to defaultResponse for deterministic scripted output. It rides the
|
|
932
|
+
// generic logging decorator so even mock calls leave llm-calls.jsonl
|
|
933
|
+
// receipts — the observability path is part of what a mock run exercises.
|
|
934
|
+
const mockAdapter = provider === 'mock'
|
|
935
|
+
? new MockAdapter({
|
|
936
|
+
echoMode: recipe.agent.mock?.echoMode ?? true,
|
|
937
|
+
...(recipe.agent.mock?.defaultResponse !== undefined
|
|
938
|
+
? { defaultResponse: recipe.agent.mock.defaultResponse }
|
|
939
|
+
: {}),
|
|
940
|
+
})
|
|
895
941
|
: undefined;
|
|
942
|
+
// -- x-gate-debt-chunks stamp (membrane dynamicHeaders, antra-tess/membrane#65)
|
|
943
|
+
// The gate records compression-debt per ledger row; debt only changes at
|
|
944
|
+
// calls, so the per-call series is its full-resolution history ("spoke ->
|
|
945
|
+
// chunk appeared -> chewed" vs "solver repacked -> queue spiked"). The value
|
|
946
|
+
// is the SAME reduction /healthz reports (cm getCompressionDebt). Late-bound
|
|
947
|
+
// through appRef because the framework outlives adapter construction and is
|
|
948
|
+
// replaced on session switch. Whenever it is unreadable — no framework yet,
|
|
949
|
+
// multi-agent process (whose debt would we even claim?), no strategy — the
|
|
950
|
+
// header is simply not sent: an unstamped call is honest, a guessed one lies.
|
|
951
|
+
let appRefForDebt: AppContext | null = null;
|
|
952
|
+
// The resident whose turn/debt we stamp. TODO(agent-framework ≥0.14): use a
|
|
953
|
+
// public getPrimaryAgentName() accessor instead of the private field read.
|
|
954
|
+
const primaryAgent = (): { name: string; agent: unknown } | null => {
|
|
955
|
+
const fw = appRefForDebt?.framework;
|
|
956
|
+
const agents = fw?.getAllAgents() ?? [];
|
|
957
|
+
const name = (fw as unknown as { primaryAgentName?: string } | undefined)?.primaryAgentName
|
|
958
|
+
?? (agents.length === 1 ? agents[0]!.name : undefined);
|
|
959
|
+
if (!name) return null;
|
|
960
|
+
const agent = agents.find((a) => a.name === name);
|
|
961
|
+
return agent ? { name, agent } : null;
|
|
962
|
+
};
|
|
963
|
+
const pendingDebtChunks = (): number | null => {
|
|
964
|
+
try {
|
|
965
|
+
const p = primaryAgent();
|
|
966
|
+
if (!p) return null;
|
|
967
|
+
const strategy = (p.agent as unknown as {
|
|
968
|
+
getContextManager?: () => { getStrategy?: () => { getCompressionDebt?: () => unknown } };
|
|
969
|
+
}).getContextManager?.()?.getStrategy?.();
|
|
970
|
+
const d = strategy?.getCompressionDebt?.() as { pendingChunks?: unknown } | undefined;
|
|
971
|
+
const n = d?.pendingChunks;
|
|
972
|
+
return typeof n === 'number' && Number.isFinite(n) && n >= 0 ? Math.round(n) : null;
|
|
973
|
+
} catch {
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
// Why the turn in progress fired (heartbeat / a channel message by whom /
|
|
979
|
+
// operator), read from the framework's active-turn trigger. Same guards as
|
|
980
|
+
// the debt getter: no framework or several agents
|
|
981
|
+
// -> null -> the origin trio is simply not sent.
|
|
982
|
+
const activeTurnTrigger = (): TurnTrigger | null => {
|
|
983
|
+
try {
|
|
984
|
+
const fw = appRefForDebt?.framework;
|
|
985
|
+
if (!fw) return null;
|
|
986
|
+
const agents = fw.getAllAgents();
|
|
987
|
+
// ONE adapter serves every agent in this process, so this hook cannot
|
|
988
|
+
// tell whose request it is decorating: stamp the primary's trigger only
|
|
989
|
+
// while no other agent (subconscious, fork, ephemeral) has a turn in
|
|
990
|
+
// flight — see stampedTrigger().
|
|
991
|
+
const t = stampedTrigger({
|
|
992
|
+
agents: agents.map((a) => a.name),
|
|
993
|
+
primary: primaryAgent()?.name,
|
|
994
|
+
triggerOf: (name) => {
|
|
995
|
+
const r = fw.getActiveTurnTrigger(name) as
|
|
996
|
+
(ReturnType<typeof fw.getActiveTurnTrigger> & { wakeChannelId?: string }) | undefined;
|
|
997
|
+
return r ? { reason: r.reason, source: r.source, channelId: r.channelId, wakeChannelId: r.wakeChannelId, counterparty: r.counterparty } : null;
|
|
998
|
+
},
|
|
999
|
+
});
|
|
1000
|
+
return t;
|
|
1001
|
+
} catch {
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
const gateTelemetryDynamicHeaders = gateTelemetryHeaders(process.env, pendingDebtChunks, activeTurnTrigger);
|
|
1007
|
+
|
|
896
1008
|
const adapter = provider === 'openai-responses'
|
|
897
1009
|
? new LoggingProviderAdapter(
|
|
898
1010
|
new OpenAIResponsesAPIAdapter({
|
|
@@ -907,7 +1019,9 @@ async function main() {
|
|
|
907
1019
|
// `openrouterAdapter` instances stay un-wrapped for auth commands and
|
|
908
1020
|
// dispose() — only the membrane sees the wrapper.
|
|
909
1021
|
: bedrockAdapter
|
|
1022
|
+
?? (mockAdapter ? new LoggingProviderAdapter(mockAdapter, llmLogPath) : undefined)
|
|
910
1023
|
?? (openrouterAdapter ? new LoggingProviderAdapter(openrouterAdapter, llmLogPath) : undefined)
|
|
1024
|
+
?? (openaiCompatibleAdapter ? new LoggingProviderAdapter(openaiCompatibleAdapter, llmLogPath) : undefined)
|
|
911
1025
|
?? (codexAdapter ? new LoggingProviderAdapter(codexAdapter, llmLogPath) : undefined)
|
|
912
1026
|
?? new LoggingAnthropicAdapter(
|
|
913
1027
|
{
|
|
@@ -929,6 +1043,29 @@ async function main() {
|
|
|
929
1043
|
: {}),
|
|
930
1044
|
}),
|
|
931
1045
|
baseURL: process.env.ANTHROPIC_BASE_URL || undefined,
|
|
1046
|
+
// Double-gated (see src/gate-telemetry.ts): GATE_TELEMETRY=1 AND a
|
|
1047
|
+
// configured base URL, so the stamp can only ever go to a
|
|
1048
|
+
// gateway the operator has declared — never to the vendor's
|
|
1049
|
+
// default endpoint (review finding on the first wiring).
|
|
1050
|
+
...(gateTelemetryDynamicHeaders ? { dynamicHeaders: gateTelemetryDynamicHeaders } : {}),
|
|
1051
|
+
// Hold this agent's cached prefix warm across idle gaps. Only fires
|
|
1052
|
+
// when the entry is actually near expiry, so a busy agent costs
|
|
1053
|
+
// nothing; only the 1h-TTL primary lane is eligible (the module
|
|
1054
|
+
// skips anything else). Off with cacheKeepalive.enabled: false.
|
|
1055
|
+
cacheKeepalive: {
|
|
1056
|
+
enabled: recipe.agent.cacheKeepalive?.enabled !== false,
|
|
1057
|
+
...(recipe.agent.cacheKeepalive?.maxIdleHours !== undefined
|
|
1058
|
+
? { maxIdleMs: recipe.agent.cacheKeepalive.maxIdleHours * 60 * 60_000 }
|
|
1059
|
+
: {}),
|
|
1060
|
+
...(recipe.agent.cacheKeepalive?.refreshAfterMinutes !== undefined
|
|
1061
|
+
? { refreshAfterMs: recipe.agent.cacheKeepalive.refreshAfterMinutes * 60_000 }
|
|
1062
|
+
: {}),
|
|
1063
|
+
// Every event — routine refreshes included — goes to stderr, so
|
|
1064
|
+
// all of them land in service-stderr.log next to
|
|
1065
|
+
// [inference-refusal]. See cache-keepalive-log.ts for why this is
|
|
1066
|
+
// not severity-routed.
|
|
1067
|
+
onEvent: logKeepaliveEvent,
|
|
1068
|
+
},
|
|
932
1069
|
},
|
|
933
1070
|
llmLogPath,
|
|
934
1071
|
() => settingsModule.getReasoning(),
|
|
@@ -1019,6 +1156,8 @@ async function main() {
|
|
|
1019
1156
|
},
|
|
1020
1157
|
};
|
|
1021
1158
|
|
|
1159
|
+
appRefForDebt = app;
|
|
1160
|
+
|
|
1022
1161
|
// Off-path refusal dragnet → ops alerts (observability M3): refusals on
|
|
1023
1162
|
// non-streamed calls (compression/summarizer drains, maintenance) never
|
|
1024
1163
|
// reach the framework's own noteRefusal — the 2026-07-15 mythos cascade
|
package/src/mcpl-config.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
8
8
|
import { resolve, dirname } from 'node:path';
|
|
9
|
+
import { REFUSAL_REACTION_BASELINE } from '@animalabs/agent-framework';
|
|
9
10
|
|
|
10
11
|
/** Default config file path, resolved from cwd. */
|
|
11
12
|
export const DEFAULT_CONFIG_PATH = resolve(process.cwd(), 'mcpl-servers.json');
|
|
@@ -134,10 +135,57 @@ export function saveAgentOverlay(
|
|
|
134
135
|
writeFileSync(overlayPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Capabilities an agent-deployed server is never granted by default: the
|
|
140
|
+
* consequential surfaces — context hooks (observation of and injection into
|
|
141
|
+
* the agent's own inference), server-initiated inference, and inference
|
|
142
|
+
* lifecycle. Bare parents on purpose: the config mask's subtree matching
|
|
143
|
+
* denies everything beneath them, present and future (afterInference, undo,
|
|
144
|
+
* state land under these the day the vocabulary grows them). A world/chat
|
|
145
|
+
* server needs none of this — channels + tools is the whole job. An operator
|
|
146
|
+
* who wants a self-deployed server to hold one of these moves the server
|
|
147
|
+
* into the recipe, where `enabledCapabilities` is theirs to write.
|
|
148
|
+
*/
|
|
149
|
+
export const AGENT_DEPLOY_DENIED_CAPABILITIES: readonly string[] = [
|
|
150
|
+
'contextHooks',
|
|
151
|
+
'inferenceRequest',
|
|
152
|
+
'inferenceLifecycle',
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
/** The allow/deny list fields where an EMPTY array carries no intent (see
|
|
156
|
+
* resolveOverlayEntry — OpenAI strict function calling forces every schema
|
|
157
|
+
* property, so agent tool calls arrive with `[]` meaning "unspecified"). */
|
|
158
|
+
const OVERLAY_LIST_FIELDS = [
|
|
159
|
+
'enabledFeatureSets',
|
|
160
|
+
'disabledFeatureSets',
|
|
161
|
+
'enabledTools',
|
|
162
|
+
'disabledTools',
|
|
163
|
+
] as const;
|
|
164
|
+
|
|
137
165
|
/**
|
|
138
166
|
* Resolve an overlay entry into a server config object (id + fields, relative
|
|
139
167
|
* `./`/`../` args resolved against the overlay file's directory). Returns
|
|
140
168
|
* null for tombstones and entries with neither command nor url.
|
|
169
|
+
*
|
|
170
|
+
* The overlay is the AGENT's file, so resolution is also where host policy
|
|
171
|
+
* for self-deployed servers lives (applied at boot AND at deploy — existing
|
|
172
|
+
* files heal without a re-deploy):
|
|
173
|
+
*
|
|
174
|
+
* - Empty allow/deny lists are treated as absent. OpenAI-style strict
|
|
175
|
+
* function calling forces every schema property, so GPT-family residents
|
|
176
|
+
* calling mcpl_deploy emit `[]` where they meant "unspecified" — and for
|
|
177
|
+
* the allowlists PRESENT-empty is deny-all under the §5.3 pin (Mica's
|
|
178
|
+
* silently eventless eidoverse, 2026-08-04). An agent that truly wants
|
|
179
|
+
* deny-all says `disabledTools: ["*"]` / `disabledFeatureSets: ["*"]`.
|
|
180
|
+
*
|
|
181
|
+
* - `enabledCapabilities` is dropped: the agent's file can narrow, never
|
|
182
|
+
* widen — a hand-written entry here could re-grant §13.4 deny-by-default
|
|
183
|
+
* paths.
|
|
184
|
+
*
|
|
185
|
+
* - `disabledCapabilities` always carries at least
|
|
186
|
+
* AGENT_DEPLOY_DENIED_CAPABILITIES (unioned with anything the entry
|
|
187
|
+
* already denies): self-deployed servers get channels + tools and
|
|
188
|
+
* nothing consequential by default.
|
|
141
189
|
*/
|
|
142
190
|
export function resolveOverlayEntry(
|
|
143
191
|
id: string,
|
|
@@ -148,9 +196,29 @@ export function resolveOverlayEntry(
|
|
|
148
196
|
if (!entry.command && !entry.url) return null;
|
|
149
197
|
const overlayDir = dirname(resolve(overlayPath));
|
|
150
198
|
const { disabled: _d, ...fields } = entry;
|
|
199
|
+
const rec = fields as Record<string, unknown>;
|
|
200
|
+
for (const k of OVERLAY_LIST_FIELDS) {
|
|
201
|
+
if (Array.isArray(rec[k]) && (rec[k] as unknown[]).length === 0) delete rec[k];
|
|
202
|
+
}
|
|
203
|
+
delete rec.enabledCapabilities;
|
|
204
|
+
// A network server the agent deployed should come back when it bounces.
|
|
205
|
+
// reconnect defaulted to false, so an entry that never said `reconnect:
|
|
206
|
+
// true` was severed PERMANENTLY by any server restart — with no signal to
|
|
207
|
+
// anyone — until the agent's own next restart, which for a long-lived
|
|
208
|
+
// resident is days away (Mythos, eventless in eidoverse after the
|
|
209
|
+
// 2026-08-04 door deploy). Websocket entries now default to reconnect
|
|
210
|
+
// unless the entry explicitly says false. Stdio entries keep the old
|
|
211
|
+
// default: reconnect does not respawn a dead child anyway (mcpl_restart
|
|
212
|
+
// is that path), so `true` there would promise something it can't do.
|
|
213
|
+
if (entry.url && rec.reconnect === undefined) rec.reconnect = true;
|
|
214
|
+
const denied = new Set<string>([
|
|
215
|
+
...AGENT_DEPLOY_DENIED_CAPABILITIES,
|
|
216
|
+
...(Array.isArray(rec.disabledCapabilities) ? (rec.disabledCapabilities as unknown[]).map(String) : []),
|
|
217
|
+
]);
|
|
151
218
|
return {
|
|
152
219
|
id,
|
|
153
|
-
...
|
|
220
|
+
...rec,
|
|
221
|
+
disabledCapabilities: [...denied].sort(),
|
|
154
222
|
...(entry.args
|
|
155
223
|
? {
|
|
156
224
|
args: entry.args.map(arg =>
|
|
@@ -206,3 +274,33 @@ export function saveMcplServers(configPath: string, servers: Record<string, Serv
|
|
|
206
274
|
const data: McplServersFile = { mcplServers: servers };
|
|
207
275
|
writeFileSync(configPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
208
276
|
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Compose the environment for a stdio MCPL child.
|
|
280
|
+
*
|
|
281
|
+
* Two host-owned values ride along with whatever the server entry declares:
|
|
282
|
+
*
|
|
283
|
+
* - `DISCORD_SUPPRESSED_REACTIONS_BASELINE` — the framework's exported
|
|
284
|
+
* refusal-annotation set (REFUSAL_REACTION_BASELINE, comma-joined), so a
|
|
285
|
+
* never-configured Discord adapter defaults to suppressing exactly the
|
|
286
|
+
* markers this host's framework stamps. Placed BEFORE the spread: an
|
|
287
|
+
* operator who sets the var on the server entry supersedes the house
|
|
288
|
+
* baseline — the host injects a default, never overrides a decision. The
|
|
289
|
+
* adapter's own precedence (file key incl. [] → legacy operator env →
|
|
290
|
+
* baseline) then decides what is actually enforced; house markers are
|
|
291
|
+
* Host semantics, and a standalone adapter without this composition stays
|
|
292
|
+
* honestly unprotected.
|
|
293
|
+
* - `AGENT_TIMEZONE` — after the spread, deliberately: the agent-facing
|
|
294
|
+
* wall clock is resolved per-recipe by the host and is not a per-server
|
|
295
|
+
* operator knob.
|
|
296
|
+
*/
|
|
297
|
+
export function composeMcplChildEnv(
|
|
298
|
+
serverEnv: Record<string, string> | undefined,
|
|
299
|
+
timeZone: string,
|
|
300
|
+
): Record<string, string> {
|
|
301
|
+
return {
|
|
302
|
+
DISCORD_SUPPRESSED_REACTIONS_BASELINE: REFUSAL_REACTION_BASELINE.join(','),
|
|
303
|
+
...(serverEnv ?? {}),
|
|
304
|
+
AGENT_TIMEZONE: timeZone,
|
|
305
|
+
};
|
|
306
|
+
}
|