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 { SessionStore, SessionManager } from '../session/index.js';
|
||||||
import { HookEngine } from '../hooks/index.js';
|
import { HookEngine } from '../hooks/index.js';
|
||||||
import { ToolRegistry, ToolExecutor, ToolPolicy, allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager } from '../tools/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 { MemoryStore } from '../memory/index.js';
|
||||||
import { createMemoryTools } from '../tools/builtin/index.js';
|
import { createMemoryTools } from '../tools/builtin/index.js';
|
||||||
import { GatewayServer } from '../gateway/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 { McpManager } from '../mcp/index.js';
|
||||||
import { SkillRegistry, SkillInstaller, loadAllSkills } from '../skills/index.js';
|
import { SkillRegistry, SkillInstaller, loadAllSkills } from '../skills/index.js';
|
||||||
import { assembleSystemPrompt } from '../prompt/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 { resolve } from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { mkdirSync } from 'fs';
|
import { mkdirSync } from 'fs';
|
||||||
@@ -33,6 +36,9 @@ export interface DaemonContext {
|
|||||||
mcpManager: McpManager;
|
mcpManager: McpManager;
|
||||||
skillRegistry: SkillRegistry;
|
skillRegistry: SkillRegistry;
|
||||||
skillInstaller: SkillInstaller;
|
skillInstaller: SkillInstaller;
|
||||||
|
agentConfigRegistry: AgentConfigRegistry;
|
||||||
|
agentRouter: AgentRouter;
|
||||||
|
sandboxManager?: SandboxManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSystemPrompt(config: Config): string {
|
function loadSystemPrompt(config: Config): string {
|
||||||
@@ -164,15 +170,32 @@ function createMessageRouter(deps: {
|
|||||||
toolExecutor: ToolExecutor;
|
toolExecutor: ToolExecutor;
|
||||||
config: Config;
|
config: Config;
|
||||||
memoryStore?: MemoryStore;
|
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>();
|
const agents = new Map<string, AgentOrchestrator>();
|
||||||
|
|
||||||
function getOrCreateAgent(channel: string, senderId: 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);
|
let agent = agents.get(sessionId);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
const session = deps.sessionManager.getSession(channel, senderId);
|
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 = {
|
const delegationConfig: DelegationConfig = {
|
||||||
compaction: deps.config.agents.delegation.compaction ?? 'fast',
|
compaction: deps.config.agents.delegation.compaction ?? 'fast',
|
||||||
memory_extraction: deps.config.agents.delegation.memory_extraction ?? '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',
|
tool_summarisation: deps.config.agents.delegation.tool_summarisation ?? 'fast',
|
||||||
complex_reasoning: deps.config.agents.delegation.complex_reasoning ?? 'complex',
|
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({
|
agent = new AgentOrchestrator({
|
||||||
modelRouter: deps.modelRouter,
|
modelRouter: deps.modelRouter,
|
||||||
systemPrompt: deps.systemPrompt,
|
systemPrompt: effectiveSystemPrompt,
|
||||||
session,
|
session,
|
||||||
toolRegistry: deps.toolRegistry,
|
toolRegistry: effectiveToolRegistry,
|
||||||
toolExecutor: deps.toolExecutor,
|
toolExecutor: deps.toolExecutor,
|
||||||
primaryTier: deps.config.agents.primary_tier ?? 'default',
|
primaryTier: effectiveTier,
|
||||||
delegation: delegationConfig,
|
delegation: delegationConfig,
|
||||||
maxDelegationDepth: deps.config.agents.max_delegation_depth ?? 3,
|
maxDelegationDepth: deps.config.agents.max_delegation_depth ?? 3,
|
||||||
compaction: deps.config.compaction.enabled ? {
|
compaction: deps.config.compaction.enabled ? {
|
||||||
@@ -198,8 +273,8 @@ function createMessageRouter(deps: {
|
|||||||
contextWindow: deps.config.models.default.context_window,
|
contextWindow: deps.config.models.default.context_window,
|
||||||
memoryStore: deps.memoryStore,
|
memoryStore: deps.memoryStore,
|
||||||
toolPolicyContext: {
|
toolPolicyContext: {
|
||||||
agent: deps.config.agents.primary_tier ?? 'default',
|
agent: effectiveTier,
|
||||||
provider: deps.config.models.default.provider,
|
provider: effectiveProvider,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
agents.set(sessionId, agent);
|
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)`);
|
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
|
// Initialize model router
|
||||||
const modelRouter = createModelRouter(config);
|
const modelRouter = createModelRouter(config);
|
||||||
|
|
||||||
@@ -426,6 +528,9 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
|||||||
toolExecutor,
|
toolExecutor,
|
||||||
config,
|
config,
|
||||||
memoryStore,
|
memoryStore,
|
||||||
|
agentConfigRegistry,
|
||||||
|
agentRouter,
|
||||||
|
sandboxManager,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Register Telegram adapter
|
// Register Telegram adapter
|
||||||
@@ -527,6 +632,9 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
|||||||
mcpManager,
|
mcpManager,
|
||||||
skillRegistry,
|
skillRegistry,
|
||||||
skillInstaller,
|
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