Files
Agentic-AI/src/agents/factory.js
T

165 lines
6.8 KiB
JavaScript

// ============================================
// 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 };