@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
298 lines • 15.1 kB
JavaScript
import { defaultPersonaOf, findPersona, intersectAllowLists, PersonaNotFoundError, personaCatalogEntry, resolvePersonaAlias, } from './personas.js';
/** The synthesized `agent`-kind tool name for an edge to `target`: `ask_<target>`, or `start_<target>` for a detached one. */
export function delegateToolName(target, options = {}) {
const slug = target.replace(/[^a-zA-Z0-9]+/g, '_');
return options.detached === true ? `start_${slug}` : `ask_${slug}`;
}
/**
* The `{ task: string }` input a delegate (`agent`-kind) tool takes, as a zero-dependency Standard
* Schema so synthesizing delegate tools never pulls in `zod` (an optional peer). The loop validates
* against it in the delegation branch of `agent-loop.ts` before delegating, mirroring
* `ToolRegistry.invoke`'s input gate for every other tool kind.
*/
const DELEGATE_JSON_SCHEMA = {
type: 'object',
properties: {
task: {
type: 'string',
description: 'What the delegate agent should do, in one self-contained instruction.',
},
},
required: ['task'],
additionalProperties: false,
};
/**
* The delegate tool's input contract.
*
* `jsonSchema.input` is NOT optional decoration — it is what makes this tool usable.
* The SDK bridge can only derive parameter shapes from a Zod schema or from this
* Standard JSON Schema extension; anything else degrades to a permissive
* `{ properties: {} }`, which tells the model the tool takes NO arguments.
*
* Without it, the loop was unwinnable for the model: it was shown a tool with no
* parameters, sent `{}` — correctly, given what it was told — and then had the call
* rejected against a `validate` that demands `{ task: string }`. A production install
* showed four delegate tools at 121 calls and 121 failures each: a 100% failure rate
* that burned the actor's whole daily token budget re-trying a call it could not get
* right, because the requirement was never communicated.
*
* `validate` stays the authority (it is what actually gates execution); this is the
* same contract, expressed in the one form the model gets to see.
*/
const delegateInputSchema = {
'~standard': {
version: 1,
vendor: '@adonis-agora/agent',
validate: (value) => {
if (typeof value === 'object' &&
value !== null &&
typeof value.task === 'string') {
return { value: { task: value.task } };
}
return { issues: [{ message: 'expected { task: string }' }] };
},
jsonSchema: {
input: () => DELEGATE_JSON_SCHEMA,
output: () => DELEGATE_JSON_SCHEMA,
},
},
};
/** Normalize a `delegatesTo` entry: a bare string is the edge with no authorization annotation. */
export function normalizeDelegateEdge(edge) {
return typeof edge === 'string' ? { agent: edge } : edge;
}
/**
* Synthesize an `agent`-kind delegate tool for each `agent→agent` edge declared via `delegatesTo`, so
* an orchestrator can call `ask_<target>({ task })` to hand work to another registered agent. The loop
* handles delegation itself (never the handler), so the handler is a no-op. Skips names already taken.
* Returns the number of delegate tools registered.
*
* Authorization: the synthesized spec carries the edge's `roles`/`ability` verbatim, so delegation is
* gated by the SAME `RolesPolicy` as every other tool — no special case, no implicit grant. An edge
* declared as a bare string carries neither, which means ADMIN-only under `DefaultToolAuthorizer` and
* an outright deny under the ability-aware authz adapter. That is deliberate (fail-closed), and the
* {@link DelegateEdge} object form is how an app opens the edge to the actors it intends.
*/
export function registerDelegateTools(registry, agents) {
let count = 0;
for (const definition of agents.list()) {
for (const edge of definition.delegatesTo ?? []) {
const { agent: target, roles, ability, detached } = normalizeDelegateEdge(edge);
const name = delegateToolName(target, { detached: detached === true });
if (registry.has(name))
continue;
const targetDefinition = agents.get(target);
// Only a flat string prompt is worth surfacing; a PromptBuilder is per-request source, so skip it.
const blurb = typeof targetDefinition?.systemPrompt === 'string'
? ` It is: ${targetDefinition.systemPrompt}`
: '';
registry.register({
name,
kind: 'agent',
targetAgent: target,
// A detached edge's description is the model's only warning that this call hands back a
// receipt: a tool advertised as returning an answer, which then returns something else, is
// a model that reports an answer it was never given.
description: detached === true
? `Start the "${target}" agent working on a task in the BACKGROUND. Returns immediately with a receipt, NOT an answer — the answer is delivered to this conversation as a separate message later. Use it for work the user does not need to sit and wait for.${blurb}`
: `Delegate a task to the "${target}" agent and get its answer.${blurb}`,
...(detached === true ? { detached: true } : {}),
inputSchema: delegateInputSchema,
...(roles !== undefined ? { roles } : {}),
...(ability !== undefined ? { ability } : {}),
},
// Loop-handled (kind 'agent'); the handler is never called.
{ execute: async () => ({}) });
count += 1;
}
}
return count;
}
/**
* Builds the per-agent {@link AgentDeps} the runner feeds `runAgentLoop`. The single-agent case is
* just the `default` definition; a named agent (registered in the {@link AgentRegistry}) supplies its
* own prompt, personas, tool allow-list and step budget. Model/store/sink/roles are shared.
*/
export class AgentDepsFactory {
config;
constructor(config) {
this.config = config;
}
/** Every registered agent definition, in registration order. */
agentDefinitions() {
return this.config.agents.list();
}
actionApprovalMode() {
return this.config.actionApprovalMode ?? 'blocking';
}
defaultAgentName() {
return this.config.defaultAgentName ?? 'default';
}
effectiveTools(definition) {
if (definition === undefined) {
return undefined;
}
const delegated = (definition.delegatesTo ?? []).map((edge) => {
const { agent, detached } = normalizeDelegateEdge(edge);
return delegateToolName(agent, { detached: detached === true });
});
if (definition.tools === undefined && delegated.length === 0) {
return undefined; // no restriction
}
return [...(definition.tools ?? []), ...delegated];
}
/** Is `name` a registered agent — or the implicit default one? */
isAgent(name) {
return name === this.defaultAgentName() || this.config.agents.get(name) !== undefined;
}
/**
* The agent a name stands for: itself when it is a registered agent (or unknown — the bare
* assistant), else the agent whose persona took the name over ({@link Persona.aliases}) and that
* persona.
*/
resolveAgent(name) {
if (this.isAgent(name)) {
return { agentName: name };
}
const alias = resolvePersonaAlias(this.agentDefinitions(), name);
return alias === undefined
? { agentName: name }
: { agentName: alias.agent, persona: alias.persona };
}
/** `agentName`'s personas as a picker reads them. Empty when it declares none. */
personaCatalog(agentName) {
return (this.config.agents.get(agentName)?.personas ?? []).map(personaCatalogEntry);
}
/** The persona `agentName` runs under when nothing names one. Undefined → none. */
defaultPersona(agentName) {
return defaultPersonaOf(this.config.agents.get(agentName));
}
/** Does `agentName` declare a persona `id`? */
hasPersona(agentName, id) {
return findPersona(this.config.agents.get(agentName), id) !== undefined;
}
/**
* The persona a turn of `agentName` runs under, most specific first: the one the send names
* (refused with {@link PersonaNotFoundError} when the agent does not declare it), else the thread's
* pinned one when this agent declares it, else the agent's default. Undefined → none.
*/
resolvePersona(args) {
if (args.requested !== undefined) {
if (!this.hasPersona(args.agentName, args.requested)) {
throw new PersonaNotFoundError(args.agentName, args.requested);
}
return args.requested;
}
const pinned = args.threadPersona ?? undefined;
if (pinned !== undefined && this.hasPersona(args.agentName, pinned)) {
return pinned;
}
return this.defaultPersona(args.agentName);
}
forAgent(agentName) {
const name = agentName ?? this.defaultAgentName();
// A name a persona took over (`Persona.aliases`): a run journaled under the old agent name — or a
// caller still naming it — is served by the agent that owns the persona, with it applied.
if (agentName !== undefined && !this.isAgent(agentName)) {
const alias = resolvePersonaAlias(this.agentDefinitions(), agentName);
const persona = alias === undefined
? undefined
: findPersona(this.config.agents.get(alias.agent), alias.persona);
if (alias !== undefined && persona !== undefined) {
return this.withPersona(this.forAgent(alias.agent), persona);
}
}
const definition = this.config.agents.get(name);
const personas = new Map();
for (const persona of definition?.personas ?? []) {
personas.set(persona.id, persona);
}
const toolAllowList = this.effectiveTools(definition);
return {
...(this.config.resolveUiCatalog !== undefined
? { resolveUiCatalog: this.config.resolveUiCatalog }
: {}),
...(this.config.uiChannelGuide !== undefined
? { uiChannelGuide: this.config.uiChannelGuide }
: {}),
...(this.config.onPresentationError !== undefined
? { onPresentationError: this.config.onPresentationError }
: {}),
model: this.config.model,
store: this.config.store,
sink: this.config.sink,
rolesPolicy: this.config.rolesPolicy,
registry: this.config.registry,
...(this.config.actionApprovalMode !== undefined
? { actionApprovalMode: this.config.actionApprovalMode }
: {}),
systemPrompt: definition?.systemPrompt ?? 'You are a helpful assistant.',
maxSteps: definition?.maxSteps ?? 8,
personas,
defaultPersona: definition?.defaultPersona ?? 'default',
...(definition?.modelId !== undefined ? { modelId: definition.modelId } : {}),
...(this.config.quota !== undefined ? { quota: this.config.quota } : {}),
...(this.config.approvalPolicy !== undefined
? { approvalPolicy: this.config.approvalPolicy }
: {}),
...(this.config.pricingStore !== undefined ? { pricingStore: this.config.pricingStore } : {}),
...(this.config.retriever !== undefined ? { retriever: this.config.retriever } : {}),
...(this.config.retrievalTopK !== undefined
? { retrievalTopK: this.config.retrievalTopK }
: {}),
...(this.config.retrievalFilter !== undefined
? { retrievalFilter: this.config.retrievalFilter }
: {}),
...(this.config.toolTransientRetry !== undefined
? { toolTransientRetry: this.config.toolTransientRetry }
: {}),
...(this.config.historyWindow !== undefined
? { historyWindow: this.config.historyWindow }
: {}),
...(this.config.messageTimestamps !== undefined
? { messageTimestamps: this.config.messageTimestamps }
: {}),
...(this.config.skills !== undefined ? { skills: this.config.skills } : {}),
...(this.config.memory !== undefined ? { memory: this.config.memory } : {}),
...(this.config.inputProcessors !== undefined
? { inputProcessors: this.config.inputProcessors }
: {}),
...(this.config.outputProcessors !== undefined
? { outputProcessors: this.config.outputProcessors }
: {}),
...(toolAllowList !== undefined ? { toolAllowList } : {}),
...(definition?.maxDelegationDepth !== undefined
? { maxDelegationDepth: definition.maxDelegationDepth }
: {}),
...(definition?.maxAgentAppearances !== undefined
? { maxAgentAppearances: definition.maxAgentAppearances }
: {}),
// From the agent's DEFINITION, never from a run's input: a durable replay rebuilds these deps
// here, and the loop's checkpoint sequence has to come out the same as the first attempt's.
...(definition?.ask !== undefined ? { ask: definition.ask } : {}),
...(definition?.intake !== undefined ? { intake: definition.intake } : {}),
};
}
/**
* A persona baked into an agent's deps — how a run journaled under an agent name that a persona has
* since taken over ({@link Persona.aliases}) is served. That run names no persona of its own (it
* predates the fold, and its input cannot change), so the persona applies the way the old agent's
* own config did: as config, re-read on every replay, with no checkpoint of its own.
*/
withPersona(deps, persona) {
const base = deps.systemPrompt;
const resolve = async (prompt, ctx) => typeof prompt === 'function' ? prompt(ctx) : prompt;
const own = persona.systemPrompt;
const systemPrompt = async (ctx) => {
const scoped = { ...ctx, persona };
if (own === undefined) {
return resolve(base, scoped);
}
const basePrompt = typeof own === 'function' ? await resolve(base, scoped) : '';
return resolve(own, { ...scoped, basePrompt });
};
const toolAllowList = intersectAllowLists(deps.toolAllowList, persona.allowedTools);
return { ...deps, systemPrompt, ...(toolAllowList !== undefined ? { toolAllowList } : {}) };
}
}
//# sourceMappingURL=agent-deps-factory.js.map