// ============================================ // Builds each domain agent and exposes it to the supervisor as a delegation // tool. // // Why agents-as-tools rather than a routing state machine: the supervisor // needs to fan out to two or three domains for one question ("how did the team // do, and who is overdue?") and then compose a single answer. Expressing that // as tool calls gives parallel delegation and a natural join for free, where a // hand-rolled router would need explicit fan-out/fan-in edges for every // combination. // // Division of labour, enforced by which tools each side holds: // • domain agents — READ ONLY. They fetch facts and return them. // • supervisor — plans, delegates, owns ALL presentation (charts, tables, // KPI rows, documents) AND performs every state-changing // action itself. // // Writes live on the supervisor for a concrete reason, not tidiness. The // approval gate is implemented with LangGraph's `interrupt()`, which suspends // the graph that owns the checkpointer. A sub-agent invoked as a tool is a // nested graph with no checkpointer of its own: an interrupt raised down there // bubbles out as an error, gets caught as a failed delegation, and the // operator is never actually asked — the assistant just narrates that it // "needs approval" while nothing is pending. Hoisting writes to the supervisor // puts every interrupt in the checkpointed graph, so approve/resume works. // // A sub-agent also cannot render, so two agents can never race to draw the // same chart, and layout is decided once with the whole picture in view. // ============================================ import { createReactAgent } from '@langchain/langgraph/prebuilt'; import { tool } from '@langchain/core/tools'; import { isGraphBubbleUp } from '@langchain/langgraph'; import { z } from 'zod'; import { agentModel } from '../orchestration/llm.js'; import { AGENTS, agentPermissions } from './registry.js'; import { canUseAnyOf, hasPermission } from '../guardrails/rbac.js'; import { RISK } from '../guardrails/policy.js'; import config from '../config/index.js'; import logger from '../utils/logger.js'; const isRead = (t) => (t.meta?.risk ?? RISK.READ) === RISK.READ; const SUB_AGENT_CONTRACT = ` You are a specialist sub-agent inside the WeLe CRM assistant. A supervisor has delegated one task to you. How to respond: - Use your tools to get real data. Never invent a number, a name or a date. - Return the FACTS you found, densely and completely: include the actual figures and rows the supervisor will need, because it cannot see your tool output. - Structure the answer as compact labelled data, not prose narration. - If a tool returns an error or empty result, say exactly that — do not paper over it with a plausible-sounding answer. - Do not format for presentation, and do not draw charts or tables; the supervisor owns how this is shown. - Stay inside your domain. If the task needs another domain, say what is missing and let the supervisor route it. `.trim(); /** Compile the ReAct agent for one domain. Read tools only — see header. */ function buildAgent(def) { return createReactAgent({ llm: agentModel(), tools: def.tools.filter(isRead), stateModifier: `${SUB_AGENT_CONTRACT}\n\n## Your domain: ${def.title}\n${def.prompt}`, }); } const compiled = new Map(); function getAgent(def) { if (!compiled.has(def.key)) compiled.set(def.key, buildAgent(def)); return compiled.get(def.key); } /** * Wrap a domain agent as a tool the supervisor can call. * The run context (user, session, guardrail runtime) is forwarded so tools * deep inside the sub-agent still see the same principal. */ function delegationTool(def) { return tool( async ({ task, context }, cfg) => { const started = Date.now(); const input = context ? `${task}\n\nContext from the conversation: ${context}` : task; try { const result = await getAgent(def).invoke( { messages: [{ role: 'user', content: input }] }, { configurable: cfg?.configurable, recursionLimit: config.guardrails.agentRecursionLimit, signal: cfg?.signal, }, ); const last = result.messages?.[result.messages.length - 1]; const text = typeof last?.content === 'string' ? last.content : (last?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('\n'); logger.debug(`🤝 ${def.key} agent done in ${Date.now() - started}ms`); return text || 'The sub-agent returned no content.'; } catch (err) { // Control-flow signals (interrupt / command) are not failures — they // must reach the checkpointed parent graph. Swallowing one here is // exactly the bug that made approvals silently never appear. if (isGraphBubbleUp(err)) throw err; logger.error(`${def.key} agent failed: ${err.message}`); return `The ${def.title} could not complete this task: ${err.message}`; } }, { name: `ask_${def.key}_agent`, description: `${def.purpose}\n\nDelegate a complete, self-contained task to the ${def.title}.`, schema: z.object({ task: z.string().describe( 'The full task in plain language, including any filters, time window and specific names or numbers. ' + 'The sub-agent cannot see the conversation, so restate everything it needs.', ), context: z.string().optional().describe('Relevant facts already established this turn.'), }), }, ); } /** * Delegation tools the given user is actually able to use. * An agent whose entire toolset is behind permissions the user lacks is hidden * rather than offered — otherwise the supervisor confidently routes to it and * the user gets a permission error instead of an answer. */ export function delegationToolsFor(user) { return AGENTS .filter((def) => canUseAnyOf(user, agentPermissions(def))) .map(delegationTool); } /** * Every state-changing tool the user is permitted to use, across all domains. * These are bound to the SUPERVISOR, not to sub-agents, so that the approval * interrupt is raised inside the checkpointed graph and can be resumed. */ export function writeToolsFor(user) { const seen = new Set(); const out = []; for (const def of AGENTS) { for (const t of def.tools) { if (isRead(t) || seen.has(t.name)) continue; if (!hasPermission(user, t.meta?.permission)) continue; seen.add(t.name); out.push(t); } } return out; } /** Routing menu injected into the supervisor prompt. */ export function agentMenuFor(user) { return AGENTS .filter((def) => canUseAnyOf(user, agentPermissions(def))) .map((def) => `- **ask_${def.key}_agent** — ${def.purpose}`) .join('\n'); } export { SUB_AGENT_CONTRACT, config };