feat: wire Docker sandboxing and agent routing into daemon
This commit is contained in:
+115
-7
@@ -6,6 +6,7 @@ import { AgentOrchestrator, type DelegationConfig } from '../backends/index.js';
|
||||
import { SessionStore, SessionManager } from '../session/index.js';
|
||||
import { HookEngine } from '../hooks/index.js';
|
||||
import { ToolRegistry, ToolExecutor, ToolPolicy, allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager } from '../tools/index.js';
|
||||
import type { Tool } from '../tools/types.js';
|
||||
import { MemoryStore } from '../memory/index.js';
|
||||
import { createMemoryTools } from '../tools/builtin/index.js';
|
||||
import { GatewayServer } from '../gateway/index.js';
|
||||
@@ -15,6 +16,8 @@ import type { InboundMessage, OutboundMessage } from '../channels/index.js';
|
||||
import { McpManager } from '../mcp/index.js';
|
||||
import { SkillRegistry, SkillInstaller, loadAllSkills } from '../skills/index.js';
|
||||
import { assembleSystemPrompt } from '../prompt/index.js';
|
||||
import { AgentConfigRegistry, AgentRouter } from '../agents/index.js';
|
||||
import { DockerSandbox, SandboxManager, createSandboxedShellTool, createSandboxedProcessStartTool } from '../sandbox/index.js';
|
||||
import { resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { mkdirSync } from 'fs';
|
||||
@@ -33,6 +36,9 @@ export interface DaemonContext {
|
||||
mcpManager: McpManager;
|
||||
skillRegistry: SkillRegistry;
|
||||
skillInstaller: SkillInstaller;
|
||||
agentConfigRegistry: AgentConfigRegistry;
|
||||
agentRouter: AgentRouter;
|
||||
sandboxManager?: SandboxManager;
|
||||
}
|
||||
|
||||
function loadSystemPrompt(config: Config): string {
|
||||
@@ -164,15 +170,32 @@ function createMessageRouter(deps: {
|
||||
toolExecutor: ToolExecutor;
|
||||
config: Config;
|
||||
memoryStore?: MemoryStore;
|
||||
agentConfigRegistry?: AgentConfigRegistry;
|
||||
agentRouter?: AgentRouter;
|
||||
sandboxManager?: SandboxManager;
|
||||
}) {
|
||||
// Cache agents by session ID to avoid recreating on every message
|
||||
// Cache agents by session ID + agent config name to avoid recreating on every message
|
||||
const agents = new Map<string, AgentOrchestrator>();
|
||||
|
||||
function getOrCreateAgent(channel: string, senderId: string): AgentOrchestrator {
|
||||
const sessionId = `${channel}:${senderId}`;
|
||||
// Resolve agent config name via routing (sender → channel → default fallback)
|
||||
const agentConfigName = deps.agentRouter?.resolve(channel, senderId);
|
||||
const agentConfig = agentConfigName ? deps.agentConfigRegistry?.get(agentConfigName) : undefined;
|
||||
|
||||
// Include agent config name in cache key so different agents aren't shared
|
||||
const sessionId = agentConfigName
|
||||
? `${channel}:${senderId}:${agentConfigName}`
|
||||
: `${channel}:${senderId}`;
|
||||
|
||||
let agent = agents.get(sessionId);
|
||||
if (!agent) {
|
||||
const session = deps.sessionManager.getSession(channel, senderId);
|
||||
|
||||
// Use agent config overrides where available, falling back to global config
|
||||
const effectiveSystemPrompt = agentConfig?.systemPrompt ?? deps.systemPrompt;
|
||||
const effectiveTier = agentConfig?.modelTier ?? deps.config.agents.primary_tier ?? 'default';
|
||||
const effectiveProvider = deps.config.models.default.provider;
|
||||
|
||||
const delegationConfig: DelegationConfig = {
|
||||
compaction: deps.config.agents.delegation.compaction ?? 'fast',
|
||||
memory_extraction: deps.config.agents.delegation.memory_extraction ?? 'fast',
|
||||
@@ -180,13 +203,65 @@ function createMessageRouter(deps: {
|
||||
tool_summarisation: deps.config.agents.delegation.tool_summarisation ?? 'fast',
|
||||
complex_reasoning: deps.config.agents.delegation.complex_reasoning ?? 'complex',
|
||||
};
|
||||
|
||||
// Clone the tool registry and replace shell tools with sandboxed versions if configured
|
||||
let effectiveToolRegistry = deps.toolRegistry;
|
||||
if (agentConfig?.sandbox && deps.sandboxManager && deps.config.sandbox.enabled) {
|
||||
effectiveToolRegistry = deps.toolRegistry.clone();
|
||||
// Lazy sandbox: create the sandboxed tools with a deferred sandbox reference
|
||||
// The sandbox is created on first use via SandboxManager.getOrCreate()
|
||||
const sandboxSessionId = sessionId;
|
||||
const sandboxManager = deps.sandboxManager;
|
||||
|
||||
// Create a proxy sandbox that lazily initializes
|
||||
const lazySandboxShell: Tool = {
|
||||
name: 'shell.exec',
|
||||
description: 'Execute a shell command inside a sandboxed container and return stdout/stderr.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: { type: 'string', description: 'The shell command to execute' },
|
||||
cwd: { type: 'string', description: 'Working directory inside the container (optional)' },
|
||||
timeout: { type: 'number', description: 'Timeout in milliseconds (default 30000)' },
|
||||
},
|
||||
required: ['command'],
|
||||
},
|
||||
execute: async (rawArgs: unknown) => {
|
||||
const sandbox = await sandboxManager.getOrCreate(sandboxSessionId);
|
||||
const tool = createSandboxedShellTool(sandbox);
|
||||
return tool.execute(rawArgs);
|
||||
},
|
||||
};
|
||||
|
||||
const lazySandboxProcess: Tool = {
|
||||
name: 'process.start',
|
||||
description: 'Start a command in the background inside a sandboxed container.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: { type: 'string', description: 'The shell command to run in the background' },
|
||||
cwd: { type: 'string', description: 'Working directory inside the container (optional)' },
|
||||
},
|
||||
required: ['command'],
|
||||
},
|
||||
execute: async (rawArgs: unknown) => {
|
||||
const sandbox = await sandboxManager.getOrCreate(sandboxSessionId);
|
||||
const tool = createSandboxedProcessStartTool(sandbox);
|
||||
return tool.execute(rawArgs);
|
||||
},
|
||||
};
|
||||
|
||||
effectiveToolRegistry.replace(lazySandboxShell);
|
||||
effectiveToolRegistry.replace(lazySandboxProcess);
|
||||
}
|
||||
|
||||
agent = new AgentOrchestrator({
|
||||
modelRouter: deps.modelRouter,
|
||||
systemPrompt: deps.systemPrompt,
|
||||
systemPrompt: effectiveSystemPrompt,
|
||||
session,
|
||||
toolRegistry: deps.toolRegistry,
|
||||
toolRegistry: effectiveToolRegistry,
|
||||
toolExecutor: deps.toolExecutor,
|
||||
primaryTier: deps.config.agents.primary_tier ?? 'default',
|
||||
primaryTier: effectiveTier,
|
||||
delegation: delegationConfig,
|
||||
maxDelegationDepth: deps.config.agents.max_delegation_depth ?? 3,
|
||||
compaction: deps.config.compaction.enabled ? {
|
||||
@@ -198,8 +273,8 @@ function createMessageRouter(deps: {
|
||||
contextWindow: deps.config.models.default.context_window,
|
||||
memoryStore: deps.memoryStore,
|
||||
toolPolicyContext: {
|
||||
agent: deps.config.agents.primary_tier ?? 'default',
|
||||
provider: deps.config.models.default.provider,
|
||||
agent: effectiveTier,
|
||||
provider: effectiveProvider,
|
||||
},
|
||||
});
|
||||
agents.set(sessionId, agent);
|
||||
@@ -384,6 +459,33 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
||||
console.log(`Loaded ${skills.length} skill(s) (${available} available)`);
|
||||
}
|
||||
|
||||
// Initialize agent config registry and router
|
||||
const agentConfigRegistry = new AgentConfigRegistry();
|
||||
if (config.agent_configs && Object.keys(config.agent_configs).length > 0) {
|
||||
agentConfigRegistry.loadFromConfig(config.agent_configs);
|
||||
console.log(`Loaded ${Object.keys(config.agent_configs).length} agent config(s)`);
|
||||
}
|
||||
const agentRouter = new AgentRouter(config.routing);
|
||||
|
||||
// Initialize sandbox manager if Docker is available
|
||||
let sandboxManager: SandboxManager | undefined;
|
||||
if (config.sandbox.enabled) {
|
||||
const dockerAvailable = await DockerSandbox.isAvailable();
|
||||
if (dockerAvailable) {
|
||||
sandboxManager = new SandboxManager(config.sandbox);
|
||||
console.log(`Docker sandbox enabled (image=${config.sandbox.image}, network=${config.sandbox.network})`);
|
||||
} else {
|
||||
console.warn('Docker sandbox enabled but Docker not available — falling back to host execution');
|
||||
}
|
||||
}
|
||||
|
||||
if (sandboxManager) {
|
||||
lifecycle.onShutdown(async () => {
|
||||
await sandboxManager!.destroyAll();
|
||||
console.log('Docker sandboxes destroyed');
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize model router
|
||||
const modelRouter = createModelRouter(config);
|
||||
|
||||
@@ -426,6 +528,9 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
||||
toolExecutor,
|
||||
config,
|
||||
memoryStore,
|
||||
agentConfigRegistry,
|
||||
agentRouter,
|
||||
sandboxManager,
|
||||
}));
|
||||
|
||||
// Register Telegram adapter
|
||||
@@ -527,6 +632,9 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
||||
mcpManager,
|
||||
skillRegistry,
|
||||
skillInstaller,
|
||||
agentConfigRegistry,
|
||||
agentRouter,
|
||||
sandboxManager,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AgentRouter } from '../agents/router.js';
|
||||
import { AgentConfigRegistry } from '../agents/registry.js';
|
||||
|
||||
describe('daemon agent routing integration', () => {
|
||||
it('resolves agent config for channel messages', () => {
|
||||
const registry = new AgentConfigRegistry();
|
||||
registry.loadFromConfig({
|
||||
assistant: { system_prompt: 'Be helpful.', model_tier: 'default', tool_profile: 'messaging', sandbox: false },
|
||||
coder: { system_prompt: 'Write code.', model_tier: 'complex', tool_profile: 'coding', sandbox: true },
|
||||
});
|
||||
|
||||
const router = new AgentRouter({
|
||||
default_agent: 'assistant',
|
||||
channels: { discord: 'coder' },
|
||||
senders: { 'telegram:admin': 'coder' },
|
||||
});
|
||||
|
||||
// Discord user gets coder
|
||||
const discordAgent = router.resolve('discord', 'user123');
|
||||
expect(discordAgent).toBe('coder');
|
||||
expect(registry.get(discordAgent!)!.systemPrompt).toBe('Write code.');
|
||||
|
||||
// Telegram admin gets coder
|
||||
const telegramAdmin = router.resolve('telegram', 'admin');
|
||||
expect(telegramAdmin).toBe('coder');
|
||||
|
||||
// Random telegram user gets assistant
|
||||
const telegramUser = router.resolve('telegram', 'random');
|
||||
expect(telegramUser).toBe('assistant');
|
||||
expect(registry.get(telegramUser!)!.systemPrompt).toBe('Be helpful.');
|
||||
});
|
||||
|
||||
it('uses default agent when no routing configured', () => {
|
||||
const router = new AgentRouter({ channels: {}, senders: {} });
|
||||
expect(router.resolve('telegram', '123')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user