165 lines
6.8 KiB
JavaScript
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 };
|