UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

205 lines 8.88 kB
/** * Agent Router * * Capability-based and load-aware agent routing for task dispatch (#916). * Selects the best available agent for a task based on declared filters * (framework requirements, inventory, resource thresholds) and real-time * metrics (CPU/memory load from #911). * * Also exposes routeMission() for executor-level routing (#1179). */ /** * Select the best executor from the provided list using filter criteria. * * Default-selection policy (per ADR §3): * 1. Sandbox-first: prefer any executor with isolation:vm or isolation:container * 2. Local fallback: isolation:none or isolation:host * 3. 503 if no executor is connected and matching * * `long_running: true` requires the 'resumable' capability. * * Returns an ExecutorRoutingResult with the selected executor and full candidate list. */ export function routeMission(executors, filter, longRunning = false) { const matched = []; const rejected = []; // Only consider connected executors const connected = executors.filter((e) => e.connected); const disconnected = executors.filter((e) => !e.connected); for (const e of disconnected) { rejected.push({ executorId: e.executorId, reason: 'executor not connected' }); } for (const executor of connected) { // Pinned executor_id if (filter.executor_id && executor.executorId !== filter.executor_id) { rejected.push({ executorId: executor.executorId, reason: `executor_id does not match '${filter.executor_id}'` }); continue; } // Long-running requires resumable if (longRunning && !executor.capabilities.includes('resumable')) { rejected.push({ executorId: executor.executorId, reason: 'long_running requires resumable capability' }); continue; } // All requested capabilities must be present if (filter.capabilities && filter.capabilities.length > 0) { const missing = filter.capabilities.filter((c) => !executor.capabilities.includes(c)); if (missing.length > 0) { rejected.push({ executorId: executor.executorId, reason: `missing capabilities: ${missing.join(', ')}` }); continue; } } matched.push({ executor, matchReason: 'matches all filter criteria', rejected, }); } if (matched.length === 0) { return { selected: undefined, candidates: [], filter }; } // ADR §3: sandbox-first policy — prefer vm/container isolation const sandboxFirst = matched.filter((c) => c.executor.capabilities.some((cap) => cap === 'isolation:vm' || cap === 'isolation:container')); let selected; if (sandboxFirst.length > 0) { selected = { ...sandboxFirst[0], matchReason: 'sandbox-first (isolation:vm/container)', rejected }; } else { selected = { ...matched[0], matchReason: 'local fallback (no vm/container executor)', rejected }; } return { selected, candidates: matched, filter }; } // ============================================================ // Matching logic // ============================================================ /** Returns a rejection reason string, or null if the agent matches. */ export function matchAgent(agent, filter) { // Direct agent targeting if (filter.agent_id) { const matches = agent.agentId === filter.agent_id || agent.instanceId === filter.agent_id || agent.logicalName === filter.agent_id; if (!matches) return `agentId/instanceId/logicalName does not match '${filter.agent_id}'`; } if (filter.agent_name) { if (agent.logicalName !== filter.agent_name) { return `logicalName '${agent.logicalName ?? 'none'}' does not match '${filter.agent_name}'`; } } if (filter.sandbox_id && agent.sandboxId !== filter.sandbox_id) { return `sandboxId '${agent.sandboxId}' does not match '${filter.sandbox_id}'`; } // Status check — skip disconnected/error agents if (agent.status === 'disconnected' || agent.status === 'error') { return `agent status is '${agent.status}'`; } // Platform requirements are declared per installed AIWG framework. A match // on any framework provider means the agent can receive work for that // provider. if (filter.platform) { const providers = new Set(agent.aiwgFrameworks?.flatMap((f) => f.providers) ?? []); if (!providers.has(filter.platform)) { return `platform providers do not include '${filter.platform}'`; } } // Framework requirements if (filter.frameworks && filter.frameworks.length > 0) { const installed = new Set(agent.aiwgFrameworks?.map((f) => f.name) ?? []); const missing = filter.frameworks.filter((f) => !installed.has(f)); if (missing.length > 0) return `missing frameworks: ${missing.join(', ')}`; } // Agent inventory requirements if (filter.agents && filter.agents.length > 0) { const installed = new Set(agent.inventory?.agents.map((a) => a.name) ?? []); const missing = filter.agents.filter((a) => !installed.has(a)); if (missing.length > 0) return `missing agents in inventory: ${missing.join(', ')}`; } // Skill inventory requirements if (filter.skills && filter.skills.length > 0) { const installed = new Set(agent.inventory?.skills.map((s) => s.name) ?? []); const missing = filter.skills.filter((s) => !installed.has(s)); if (missing.length > 0) return `missing skills in inventory: ${missing.join(', ')}`; } // CPU load threshold if (filter.max_cpu_percent !== undefined && agent.latestMetrics) { if (agent.latestMetrics.cpu_percent > filter.max_cpu_percent) { return `CPU ${agent.latestMetrics.cpu_percent.toFixed(1)}% exceeds max ${filter.max_cpu_percent}%`; } } // Memory requirements (convert GB → bytes) if (filter.min_memory_gb !== undefined && agent.latestMetrics) { const freeBytes = agent.latestMetrics.memory_total_bytes - agent.latestMetrics.memory_used_bytes; const freeGB = freeBytes / (1024 ** 3); if (freeGB < filter.min_memory_gb) { return `free memory ${freeGB.toFixed(1)}GB below min ${filter.min_memory_gb}GB`; } } return null; // matches } /** * Score an agent for ranking (lower = better). * Primary: CPU percent. Secondary: memory usage percent. */ function agentScore(agent) { const cpu = agent.latestMetrics?.cpu_percent ?? 50; const memPct = agent.latestMetrics && agent.latestMetrics.memory_total_bytes > 0 ? (agent.latestMetrics.memory_used_bytes / agent.latestMetrics.memory_total_bytes) * 100 : 50; return cpu * 0.7 + memPct * 0.3; } /** * Select the best agent from candidates using filter criteria. * Returns a RoutingResult with selected agent and full candidate list. */ export function routeTask(agents, filter) { const matched = []; const rejected = []; for (const agent of agents) { const reason = matchAgent(agent, filter); if (reason === null) { matched.push({ sandboxId: agent.sandboxId, sandboxName: agent.sandboxName, agent, matchReason: 'matches all filter criteria', }); } else { rejected.push({ agentId: agent.agentId, sandboxId: agent.sandboxId, reason }); } } // Sort matched agents by score (lower = better) matched.sort((a, b) => agentScore(a.agent) - agentScore(b.agent)); // Apply fallback if no direct match if (matched.length === 0 && filter.fallback && filter.fallback.strategy !== 'none') { const relaxedFilter = { ...filter }; if (filter.fallback.strategy === 'any_with_framework') { // Relax: keep only framework + status requirements delete relaxedFilter.agents; delete relaxedFilter.skills; delete relaxedFilter.max_cpu_percent; delete relaxedFilter.min_memory_gb; delete relaxedFilter.agent_id; delete relaxedFilter.agent_name; relaxedFilter.fallback = undefined; } else if (filter.fallback.strategy === 'any') { // Relax everything except sandbox affinity return routeTask(agents, { sandbox_id: filter.sandbox_id }); } return routeTask(agents, relaxedFilter); } const selected = matched[0]; if (selected) { selected.rejected = rejected.map((r) => ({ agentId: r.agentId, reason: r.reason })); } return { selected, candidates: matched, filter, }; } //# sourceMappingURL=agent-router.js.map