The tool registry
RPC_AGENT_TOOLS is the only registration — the types derived from it, the mode and role filters, and the agent map the product reads.
One as const array is the entire registration surface. No enum, no worker list, no second file to update.
const RPC_AGENT_TOOLS = [
...staffTools,
...campusTools,
...departmentTools,
// …every domain array
] as constDerived types
type AnyAgentTool = (typeof RPC_AGENT_TOOLS)[number]
type AgentToolEvent = AnyAgentTool['event']
// 'list.staff' | 'create.department' | …
type AgentToolPayload = {
[T in AnyAgentTool as T['event']]: AgentToolRequest<z.input<T['schema']>>
}AgentToolPayload uses each tool's Zod input type, not z.infer's output — the payload crosses RMQ as JSON, so args is what a caller sends, before defaults and transforms. That matches AgentToolCaller.call and execute().
Type a handler payload as AgentToolPayload[typeof createDepartmentTool.event], never a hand-written interface. Change the schema and the handler breaks at compile time.
These types only work because defineAgentTool preserved each literal event and concrete schema.
Mode and role filters
How a definition becomes "on Rover's graph" or "on Angie's graph" without anyone editing a worker array.
const toolsForMode = (mode: AgentRunModeName): ReadonlyArray<AnyAgentTool> => {
const allowed = TOOL_SCOPES_BY_MODE[mode]
return RPC_AGENT_TOOLS.filter((definition) =>
allowed.includes(definition.scope),
)
}
const toolsForRole = (
role: AgentToolRole,
mode: AgentRunModeName,
): ReadonlyArray<AnyAgentTool> => {
const allowed = TOOL_SCOPES_BY_MODE[mode]
return RPC_AGENT_TOOLS.filter(
(definition) =>
(definition.shared || definition.roles?.includes(role)) &&
allowed.includes(definition.scope),
)
}| Helper | Returns | Called by |
|---|---|---|
toolsForMode(mode) | Every RPC tool whose scope is allowed in that run mode. Filtered again afterwards to drop tools with a non-empty roles array. | The supervisor, via buildAgentTools |
toolsForRole(role, mode) | Tools listing that role or marked shared, still narrowed by run mode. Not a partition — a tool may list several roles. | Angie / Bean / Sarah, via buildRoleTools |
Both wrappers then pass each definition through toRpcProxy. You never call these yourself when adding a tool — tagging the definition is enough.
The agent map
The same registry is how the product describes who has which tool. libs/shared/src/naalya-ai/agent-map.ts backs both the HTTP registry endpoint and the worker-local get_agent_map tool, so the chat roster and the profile list cannot drift.
| Helper | What it does |
|---|---|
ROLE_CAPABILITIES | Maps class_teacher → isClassTeacher, and so on — the single source of truth for eligibility. Key order is the delegate roster's order, and it reaches the supervisor prompt, so reordering is a behaviour change. |
eligibleAgentRoles(capabilities) | Which of the three roles this caller actually holds. Used by the registry and by the worker's selectSubagents. |
scopeAgentMap(map, capabilities) | Supervisor (no availability.capability) plus each eligible subagent. Filters; never rebuilds. |
buildAgentMap(mode?) | Fresh map from toolsForMode / toolsForRole. Default mode is school (the tool superset); 'campus' narrows each agent to what that surface reaches. Excludes worker-local tools — they never cross the RPC seam this map derives from, so get_agent_map splices them into its own copy. |
filterAgentMapByAgent(map, agentRef) | Resolves "Angie" or "class_teacher", case-insensitively. On a miss, returns every valid id/persona pair so the caller can compose a model-readable error. |
resolveSearchScope(map, agentRef?) | Whole map, one agent, or zero agents on a bad ref — never throws. |
searchAgentTools(map, query, agentRef?) | Keyword against name/event/description, grouped by tool (keyed by event), every holder listed once. Empty query → { tools: [] }, not an error. |
You do not edit this file when you add a tool — buildAgentMap already reads RPC_AGENT_TOOLS. You do add a ROLE_CAPABILITIES row (and a worker SUBAGENT_FACTORIES entry) if you invent a fourth role, which needs a product reason.
AgentToolCaller
type AgentToolCaller = {
call<E extends AgentToolEvent>(
event: E,
payload: AgentToolPayload[E],
): Promise<AgentToolResult>
}The only surface the proxy needs from the RPC transport. Structural, not a Nest interface, so the graph loads without decorator metadata (LangGraph Studio runs graphs through tsx). The worker's AgentToolService is the production implementation — see the hop.
Where to go next
Defining a tool
defineAgentTool and its eight fields — naming and verbs, run-mode visibility, which graph gets the tool, and what may never be in the schema.
The RPC hop
Every helper from model.invoke to the domain service — worker proxy, RabbitMQ call, execute(), tenant scope, both auth postures, and audit.