@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
2,045 lines • 114 kB
JavaScript
import { pathToFileURL } from 'node:url';
import { personalAgentGate, personalAgentScopes } from '../src/a2a/gate.js';
import { notifyActionProposalAdmitted } from '../src/action-proposal-listeners.js';
import { assertIndependentActionRuntime } from '../src/action-proposal-runtime.js';
import { ActionProposalService, ActionProposalServiceError, } from '../src/action-proposal-service.js';
import { validateActionProposalListQuery } from '../src/action-proposal-transitions.js';
import { DEFAULT_ALLOWED_ATTACHMENT_CONTENT_TYPES, DEFAULT_MAX_ATTACHMENT_BYTES, } from '../src/attachment-limits.js';
import { assertRunnable, engineOnlyModel } from '../src/engine.js';
import { ActionProposalExecutor, ActionProposalWorker, AgentDepsFactory, AgentGenui, AgentRegistry, AgentService, AgentSseEncoder, ALL_AGENTS, AnonymousActorResolver, AttachmentInventoryError, AttachmentRefusedError, ChatQueueError, ChatQueueService, DefaultToolAuthorizer, discoverTools, evaluateGovernanceGate, evaluateOwnership, governanceQueries as governanceQueriesFactories, HumanReplyMismatchError, InlineAgentRunner, InProcessTokenStreamSink, isQuotaProvider, LedgerQuotaProvider, lucidStoreConnection, ModelNotAllowedError, memoryForgetVerdict, offerMemories, offerSkills, PersonaNotFoundError, parseStreamCursor, pricingStores, QuotaBlockedError, REQUESTER_APPROVER, RegenerateNeedsThreadError, RunNotActiveError, registerDelegateTools, registerFunctionalTool, registerToolsFromBarrel, resolveActorResolver, resolvePersonaAlias, ToolRegistry, toApprovalPolicy, toModelCatalog, UnknownAgentError, withActorLabel, withActorLabels, } from '../src/index.js';
import { McpToolImporter } from '../src/mcp-client/index.js';
import { createToolUiChannelGuide, } from '../src/negotiated-tool-ui.js';
import { ensureModelPricing } from '../src/pricing/boot-pricing.js';
import { setTelescopeGovernanceQueries } from '../src/telescope/governance-registry.js';
import { validateUiCapabilities } from '../src/ui-capabilities.js';
/** A send's `mode`, or `null` when it names none of the three. `interrupt: true` is `'interrupt'`. */
function sendMode(body) {
if (body.mode === undefined) {
return body.interrupt === true ? 'interrupt' : 'auto';
}
return body.mode === 'auto' || body.mode === 'queue' || body.mode === 'interrupt'
? body.mode
: null;
}
/**
* The models a provider describes for boot pricing — none when it describes none, or when its
* `describeModels` throws: pricing is bookkeeping, never a reason for the app to stay down.
*/
function describedModels(model) {
try {
return model.describeModels?.() ?? [];
}
catch (error) {
console.warn('[@adonis-agora/agent] describeModels() threw; boot pricing skipped.', error);
return [];
}
}
/** The catalog a model provider carries (`aiSdkModels(…).catalog`), if it carries one. */
function catalogOf(model) {
const catalog = model?.catalog;
return typeof catalog === 'object' &&
catalog !== null &&
typeof catalog.list === 'function'
? catalog
: undefined;
}
/** A decision's `via` is provenance persisted on the call — bounded like any other stored label. */
const MAX_VIA_LENGTH = 64;
/** `remember` from a decision body: `false` when absent, `null` when malformed. */
function decisionRemember(claimed) {
if (claimed === undefined)
return false;
return typeof claimed === 'boolean' ? claimed : null;
}
/** `via` from a decision body: `'web'` when absent (an HTTP decision), `null` when malformed. */
function decisionVia(claimed) {
if (claimed === undefined)
return 'web';
return typeof claimed === 'string' && claimed.length > 0 && claimed.length <= MAX_VIA_LENGTH
? claimed
: null;
}
/** How many attachments one message may name. */
const MAX_ATTACHMENTS_PER_MESSAGE = 10;
/**
* How many entries `GET <path>/attachments` returns. Fixed rather than caller-supplied: the route
* exists so a composer can show the files someone uploaded; a host that needs real paging over a
* large inventory has `AgentService.listAttachments` and its own store's `list`.
*/
const ATTACHMENT_PAGE_SIZE = 50;
/**
* Reduce a send's `attachments` to `{ mediaId }` refs, or say why not. One shape: a ref names an
* upload, nothing more — a full attachment (url, name, …) is refused rather than trimmed, so a client
* that still sends one learns it instead of relying on it; the url is the store's to mint.
*/
function attachmentRefs(claimed) {
if (claimed === undefined || claimed === null)
return [];
if (!Array.isArray(claimed))
return 'attachments must be an array of { mediaId }';
if (claimed.length > MAX_ATTACHMENTS_PER_MESSAGE) {
return `a message may carry at most ${MAX_ATTACHMENTS_PER_MESSAGE} attachments`;
}
const refs = [];
for (const entry of claimed) {
if (typeof entry !== 'object' || entry === null || !('mediaId' in entry)) {
return 'each attachment must be an object with a mediaId';
}
if (Object.keys(entry).some((key) => key !== 'mediaId')) {
return 'attachments are { mediaId } refs — send nothing but the id';
}
const { mediaId } = entry;
if (typeof mediaId !== 'string' || mediaId.length === 0) {
return 'each attachment must carry a non-empty string mediaId';
}
refs.push({ mediaId });
}
return refs;
}
/**
* Wires `@adonis-agora/agent` into the AdonisJS application from `config/agent.ts`:
*
* - `register()` binds the shared `ToolRegistry` + `AgentRegistry` singletons.
* - `boot()` discovers tools (the generated `hooks/tools` barrel first, then an `app/agent_tools`
* readdir fallback), registers config-level functional tools and synthesizes `agent`-kind delegate
* tools for `delegatesTo` edges, builds the runtime graph (store/sink/quota/authorizer/actor-resolver
* → deps factory → the inline runner → the `AgentService` facade), and mounts the `/agent` routes
* under `config.path` — plus optional routes (`POST /agent/attachments` when `attachments` is
* configured; `/agent/approvals/mine` when `governanceQueries` is configured; the cross-actor
* `/agent/governance/*` read-model when `governanceQueries` is configured AND a `governanceAuthorize`
* gate exists).
*
* Every route resolves the acting actor (401 if it can't). Run/thread routes (stream re-attach,
* cancel, tool-call approve/reject, `threads/:id` read/delete/fork) are additionally owner-scoped:
* a caller may act only on runs/threads it owns, unless it is governance-privileged (`governanceAuthorize`).
* The cross-actor `/agent/governance/*` read-model fails closed by omission: without a
* `governanceAuthorize` gate it is not mounted at all (404). `approvals/mine` is always scoped to the
* caller's own pending approvals and mounts regardless of the gate.
*
* The selected store (`lucid`/`memory`) is built lazily from the config so its peer (`@adonisjs/lucid`)
* loads only when chosen. Agent lifecycle events are emitted structurally by core onto the
* `agora:agent:*` diagnostics channel when `@adonis-agora/diagnostics` is installed — no bridge needed.
*/
export default class AgentProvider {
app;
#store = null;
#actionProposalWorker = null;
#sink = null;
#actorDirectory = null;
#mcpTools = null;
/** Whatever was built at boot that may own a schema: the store, the pricing store, the read-model. */
#schemaOwners = [];
/** What boot pricing checks once the tables exist (see `start`); `null` once it ran. */
#pricingCheck = null;
/** The boot pricing check in flight (awaited by `shutdown` so a test never leaks it). */
#pricingCheckRun = null;
/** The engine running the turns instead of the loop (`engine` in the config), when there is one. */
#engine;
#genuiCatalog;
#sandboxClient;
constructor(app) {
this.app = app;
}
register() {
this.app.container.singleton(ToolRegistry, () => new ToolRegistry());
this.app.container.singleton(AgentRegistry, () => {
const config = this.app.config.get('agent', {});
const registry = new AgentRegistry();
registry.register({
name: config.defaultAgent?.name ?? 'default',
...config.defaultAgent,
});
for (const definition of config.agents ?? []) {
registry.register(definition);
}
return registry;
});
}
async boot() {
const config = this.app.config.get('agent', {});
const registry = await this.app.container.make(ToolRegistry);
const agents = await this.app.container.make(AgentRegistry);
const defaultRoles = config.defaultRoles ?? [];
// ── Tool discovery: generated barrel first, else the app/agent_tools readdir fallback ──
// Deferred to `app.booted()` because discovery IMPORTS each tool file, running its top-level
// side effects. A tool that imports an Adonis service singleton at module top (e.g.
// `@adonisjs/lucid/services/db`) evaluates it right here — and during `boot()` that singleton's
// own `app` can still be uninitialized (notably in a pruned production build, surfacing as
// `Cannot read properties of undefined (reading 'booted')`), so the import throws. The per-file
// guard in `discoverTools` now turns that into a skipped tool rather than zero tools, but the
// tool is still MISSING. Importing after the app is booted lets those top-level service
// singletons resolve, so tools may use ordinary top-level imports. Safe to populate here: the
// registry is a container singleton captured by the deps factory (built below) and read
// per-request, and `booted` callbacks run before the HTTP server accepts traffic.
const barrel = await this.#loadGeneratedToolsBarrel();
this.app.booted(async () => {
if (barrel) {
await registerToolsFromBarrel(registry, barrel, defaultRoles, this.app);
}
else {
await discoverTools(registry, this.app.makePath('app/agent_tools'), defaultRoles, this.app);
}
// AFTER the app's own tools: the registry is keyed by name and nothing else, so importing
// first would let a remote server's `search` silently take the name the app's own `search`
// holds — a substitution invisible from everywhere else. Ordered here, the collision is
// detected and the remote tool is skipped with a warning naming both claimants.
await this.#importMcpTools(config, registry);
});
// Config-level functional tools (defineTool), then synthesized delegate tools.
for (const tool of config.tools ?? []) {
registerFunctionalTool(registry, tool, defaultRoles);
}
registerDelegateTools(registry, agents);
// Generative UI: the catalog's tools, registered like config-level functional tools, and the
// boot-time catalog bound for injection.
let resolveUiCatalog;
let uiChannelGuide;
if (config.genui !== undefined) {
const setup = await config.genui({
make: (klass) => this.app.container.make(klass),
app: this.app,
});
resolveUiCatalog = async (scope) => {
const serverScope = {
...scope,
...(scope.actor.tenantRef !== undefined ? { tenant: scope.actor.tenantRef } : {}),
};
return (await (setup.resolveCatalog?.(serverScope) ?? setup.catalog));
};
if (setup.channels !== undefined) {
uiChannelGuide = createToolUiChannelGuide({
channels: setup.channels,
...(setup.base !== undefined ? { base: setup.base } : {}),
resolveCatalog: resolveUiCatalog,
});
}
for (const tool of setup.tools) {
registerFunctionalTool(registry, tool, defaultRoles);
}
this.app.container.bindValue(AgentGenui, new AgentGenui(setup.catalog, setup.resolveCatalog, setup.channels, setup.base ?? {}, setup.sandboxClient));
this.#genuiCatalog = setup.catalog;
this.#sandboxClient = setup.sandboxClient;
}
// ── Runtime graph ──
// An engine (`engine` in the config) runs the turns instead of the loop: `model` is optional
// then, and `durable: true` (the LOOP's durable runner) is refused unless the engine is durable.
const engine = typeof config.engine === 'function' ? await config.engine() : config.engine;
assertRunnable({ model: config.model, engine, durable: config.durable });
this.#engine = engine;
const model = config.model === undefined && engine !== undefined
? engineOnlyModel(engine)
: await this.#resolveModel(config);
const store = await this.#resolveStore(config);
const sink = await this.#resolveSink(config);
// One budget: `{ limits }` over the usage ledger, or a `QuotaProvider` of your own — reported by
// `GET <path>/quota` and gating a send (`429`). Omitted → reported off the ledger, never gated.
const quotaProvider = config.quota === undefined
? undefined
: isQuotaProvider(config.quota)
? config.quota
: new LedgerQuotaProvider(store, undefined, config.quota.limits, {
...(config.quota.warnAt !== undefined ? { warnAt: config.quota.warnAt } : {}),
...(config.quota.countEstimatedCost !== undefined
? { countEstimatedCost: config.quota.countEstimatedCost }
: {}),
});
const pricingStore = await this.#resolvePricing(config);
// Governance read-model is resolved after pricing so the Lucid read-model prices its rollups
// against the same live prices the loop's cost fold uses.
const governance = await this.#resolveGovernance(config, pricingStore);
// Push it into the telescope extension's internal registry so its governance-backed data
// providers can read the SAME authoritative read-model the `/agent/governance/*` routes use —
// see `src/telescope/governance-registry.ts` for why this isn't a container binding. A no-op
// import when the host never wires `agentTelescopeExtension()` into `config/telescope.ts`.
setTelescopeGovernanceQueries(governance);
const retriever = await this.#resolveRetriever(config);
const attachmentStaging = await this.#resolveAttachmentStaging(config, store);
const authorizer = this.#resolveAuthorizer(config, defaultRoles);
// No resolver → the routes are PUBLIC and each browser is its own anonymous actor (an HttpOnly
// cookie). Said at boot, with the one line that switches to authenticated mode.
if (config.actorResolver === undefined) {
console.warn(`[@adonis-agora/agent] No actorResolver configured: the /${(config.path ?? 'agent').replace(/^\/+|\/+$/g, '')}/* endpoints are PUBLIC and every browser is its own anonymous actor. ` +
'To require login, set `actorResolver: new AuthActorResolver()` in config/agent.ts.');
}
const actorResolver = config.actorResolver ?? new AnonymousActorResolver();
// Read-side identity lookup for governance/dashboard surfaces (optional; renders raw refs if unset).
const actorDirectory = await this.#resolveActorDirectory(config);
this.#store = store;
this.#sink = sink;
this.#actorDirectory = actorDirectory;
this.#schemaOwners = [store, pricingStore, governance, sink];
this.#pricingCheck = { models: describedModels(model), pricingStore, config };
const onPresentationError = config.onPresentationError ?? (await this.#logPresentationErrors());
const factory = new AgentDepsFactory({
...(onPresentationError !== undefined ? { onPresentationError } : {}),
model,
store,
sink,
rolesPolicy: authorizer,
registry,
agents,
...(resolveUiCatalog !== undefined ? { resolveUiCatalog } : {}),
...(uiChannelGuide !== undefined ? { uiChannelGuide } : {}),
...(config.actionApprovalMode !== undefined
? { actionApprovalMode: config.actionApprovalMode }
: {}),
defaultAgentName: config.defaultAgent?.name ?? 'default',
...(config.approvalPolicy !== undefined
? { approvalPolicy: toApprovalPolicy(config.approvalPolicy) }
: {}),
...(pricingStore !== undefined ? { pricingStore } : {}),
...(retriever !== undefined ? { retriever } : {}),
...(config.retrievalTopK !== undefined ? { retrievalTopK: config.retrievalTopK } : {}),
...(config.retrievalFilter !== undefined ? { retrievalFilter: config.retrievalFilter } : {}),
...(config.toolTransientRetry !== undefined
? { toolTransientRetry: config.toolTransientRetry }
: {}),
...(config.historyWindow !== undefined ? { historyWindow: config.historyWindow } : {}),
...(config.messageTimestamps !== undefined
? { messageTimestamps: config.messageTimestamps }
: {}),
// Personal agents (A2A) resolve no memory or skill scope — see `personalAgentScopes`.
...(config.skills !== undefined
? { skills: { ...config.skills, scopes: personalAgentScopes(config.skills.scopes) } }
: {}),
...(config.memory !== undefined
? { memory: { ...config.memory, scopes: personalAgentScopes(config.memory.scopes) } }
: {}),
...(config.inputProcessors !== undefined ? { inputProcessors: config.inputProcessors } : {}),
...(config.outputProcessors !== undefined
? { outputProcessors: config.outputProcessors }
: {}),
});
// `durable: true` runs each turn as a replay-safe `@adonis-agora/durable` workflow; it degrades
// gracefully to the in-process runner when the durable peer isn't installed/configured.
// The thread message queue: a send on a thread whose turn is still running waits here and
// starts when that turn settles. One instance, shared by the service (which queues) and the
// runner (which drains); inert on a store that is not a `ChatQueueStore`.
const queue = new ChatQueueService(store, sink, {
...(quotaProvider !== undefined ? { quota: quotaProvider } : {}),
...(attachmentStaging !== undefined ? { attachments: attachmentStaging } : {}),
resolveTarget: (message) => service.queuedTarget(message),
});
assertIndependentActionRuntime(config.actionApprovalMode, store, config.backgroundActorResolver, queue.supported);
if (config.actionApprovalMode === 'independent') {
const executionStore = store;
const executor = new ActionProposalExecutor({
resolver: config.backgroundActorResolver,
resolveExecution: async (proposal) => {
const name = proposal.executionContext?.agentName ?? factory.defaultAgentName();
if (!factory.isAgent(name))
throw new Error(`Action proposal agent no longer exists: ${name}`);
const deps = factory.forAgent(name);
const personaId = proposal.executionContext?.persona;
if (personaId !== undefined && !deps.personas.has(personaId))
throw new Error(`Action proposal persona no longer exists: ${personaId}`);
const persona = personaId === undefined ? undefined : deps.personas.get(personaId);
const allowedTools = persona?.allowedTools === undefined
? deps.toolAllowList
: deps.toolAllowList === undefined
? persona.allowedTools
: deps.toolAllowList.filter((tool) => persona.allowedTools.includes(tool));
return {
registry: deps.registry,
rolesPolicy: deps.rolesPolicy,
...(onPresentationError !== undefined ? { onPresentationError } : {}),
...(resolveUiCatalog !== undefined ? { resolveUiCatalog } : {}),
...(persona !== undefined ? { persona } : {}),
...(allowedTools !== undefined ? { allowedTools } : {}),
};
},
});
this.#actionProposalWorker = new ActionProposalWorker({
store: executionStore,
executor,
workerId: `agora-${crypto.randomUUID()}`,
...config.actionProposalWorker,
onAdmitted: async (outcome) => {
try {
await config.actionProposalWorker?.onAdmitted?.(outcome);
}
finally {
await notifyActionProposalAdmitted(outcome);
}
},
onError: (error) => console.error('[@adonis-agora/agent] Action proposal worker failed', error),
});
}
if (this.#actionProposalWorker)
this.app.container.bindValue(ActionProposalWorker, this.#actionProposalWorker);
const runner = engine !== undefined
? await engine.createRunner({
factory,
store,
sink,
registry,
queue,
make: (binding) => this.app.container.make(binding),
...this.#appKey(),
...(pricingStore !== undefined ? { pricingStore } : {}),
...(config.priceCatalog !== undefined
? { priceCatalog: config.priceCatalog }
: this.app.getEnvironment() === 'test'
? { priceCatalog: { modelsDev: false } }
: {}),
})
: config.durable === true
? ((await this.#resolveDurableRunner(factory, store, queue, sink)) ??
new InlineAgentRunner(factory, store, queue))
: new InlineAgentRunner(factory, store, queue);
const service = new AgentService(runner, store, factory, {
queue,
...(config.backgroundActorResolver !== undefined
? { backgroundActorResolver: config.backgroundActorResolver }
: {}),
...(config.actionApprovalMode === 'independent'
? {
actionProposals: new ActionProposalService(store, factory.forAgent().approvalPolicy, config.actionProposalText),
}
: {}),
// No `models` → the catalog the model provider carries (`aiSdkModels`), else none.
...(config.models !== undefined
? { models: toModelCatalog(config.models) }
: catalogOf(model) !== undefined
? { models: catalogOf(model) }
: {}),
...(attachmentStaging !== undefined ? { attachments: attachmentStaging } : {}),
quota: quotaProvider !== undefined
? { provider: quotaProvider, gated: true }
: { provider: new LedgerQuotaProvider(store), gated: false },
});
this.app.container.bindValue(AgentService, service);
await this.#registerRoutes(config, service, actorResolver, agents, attachmentStaging, governance, config.governanceAuthorize, pricingStore, actorDirectory);
}
/**
* Provision (or repair) the agent tables once, as the app starts — before the HTTP server takes a
* request and before a test runner opens its first transaction. Left to first use, the DDL ran
* inside whatever the first caller had open: a Japa suite's global transaction held the locks the
* DDL needed and the run hung.
*
* Every store that manages its own schema exposes `ensureSchema()` (the three Lucid stores and the
* Lucid token sink do, and it is a no-op under `autoCreateTables: false`); a custom store may too. Skipped for ace commands
* (`console`): `migration:run` must not find the tables already made by the app it is booting, and
* `list:routes` must not need a database. A command that does use the agent (a durable worker)
* falls back to first use, which is the same idempotent call.
*
* A failure here is reported, not fatal: the app may be starting ahead of its database, and the
* first use retries — and surfaces the error to the caller if it is still there.
*/
async start() {
const owners = this.#schemaOwners;
this.#schemaOwners = [];
if (this.app.getEnvironment() === 'console')
return;
for (const owner of owners) {
const ensureSchema = owner?.ensureSchema;
if (typeof ensureSchema !== 'function')
continue;
try {
await ensureSchema.call(owner);
}
catch (error) {
console.warn('[@adonis-agora/agent] Could not provision the agent tables at startup; it will be retried on first use.', error);
}
}
this.#actionProposalWorker?.start();
this.#pricingCheckRun = this.#checkPricing();
}
/**
* Seed a configured model's missing price row from models.dev and warn about one that would record
* no cost (see `priceCatalog`). In the background: a slow catalog must not hold the boot up. Skipped
* under `NODE_ENV=test` unless `priceCatalog` is set explicitly.
*/
async #checkPricing() {
const check = this.#pricingCheck;
this.#pricingCheck = null;
if (check === null || check.models.length === 0)
return;
const catalogConfig = check.config.priceCatalog;
if (catalogConfig === undefined && this.app.getEnvironment() === 'test')
return;
await ensureModelPricing({
models: check.models,
pricingStore: check.pricingStore,
catalog: catalogConfig ?? {},
log: {
info: (message) => console.info(message),
warn: (message) => console.warn(message),
},
}).catch(() => undefined);
}
async shutdown() {
await this.#pricingCheckRun;
this.#pricingCheckRun = null;
this.#pricingCheck = null;
await this.#engine?.shutdown?.();
this.#engine = undefined;
await this.#actionProposalWorker?.stop();
this.#actionProposalWorker = null;
// The Lucid store shares the app's `db` (it owns no connection to close); the in-process sink
// holds only per-run buffers that GC with the provider. Drop refs so a hot reload starts clean.
// An MCP client DOES own a connection — a spawned stdio child or an HTTP session — so it closes.
await this.#mcpTools?.close();
this.#mcpTools = null;
this.#store = null;
this.#sink = null;
this.#actorDirectory = null;
this.#schemaOwners = [];
}
/**
* Import each configured MCP server's tools into the shared registry.
*
* A failure here is the importer's own to report: a server that cannot be reached costs its own
* tools and leaves the app booting, unless it declared `required: true` — in which case the throw
* propagates and boot fails, which is what that flag asks for.
*/
async #importMcpTools(config, registry) {
const servers = config.mcpServers ?? [];
if (servers.length === 0) {
return;
}
const importer = new McpToolImporter(servers, registry, {
warn: (message) => console.warn(`[@adonis-agora/agent] ${message}`),
});
this.#mcpTools = importer;
await importer.start();
}
// ── resolution helpers ────────────────────────────────────────────────────
/** The app's `APP_KEY` (a `Secret` in `config/app.ts`), for an engine that signs something. */
#appKey() {
const raw = this.app.config.get('app.appKey', undefined);
const released = typeof raw?.release === 'function'
? raw.release()
: raw;
return typeof released === 'string' && released.length > 0 ? { appKey: released } : {};
}
async #resolveModel(config) {
const model = config.model;
if (typeof model === 'function') {
return model();
}
return model;
}
async #resolveStore(config) {
const name = config.store;
if (name && config.stores?.[name]) {
return config.stores[name]({ app: this.app });
}
const { InMemoryAgentStore } = await import('../src/testing/in-memory-store.js');
return new InMemoryAgentStore();
}
async #resolveSink(config) {
const sink = config.sink;
if (sink === undefined)
return new InProcessTokenStreamSink();
return typeof sink === 'function' ? sink({ app: this.app }) : sink;
}
/**
* Build the read-side actor directory from config (an `ActorDirectoryFactory` thunk or a ready
* instance). `undefined` → governance/dashboard surfaces render raw opaque `actorRef`s.
*/
async #resolveActorDirectory(config) {
const directory = config.actorDirectory;
if (directory === undefined)
return null;
return typeof directory === 'function' ? directory({ app: this.app }) : directory;
}
/**
* The Lucid connection of the main store when it is a `stores.lucid()` store (`null` = the app's
* default connection), or `undefined` when the main store isn't Lucid. Used to default the pricing
* store and governance read-model to the same connection the agent already persists on.
*/
#mainStoreLucidConnection(config) {
const name = config.store;
const factory = name ? config.stores?.[name] : undefined;
return lucidStoreConnection(factory);
}
/**
* Build the pricing store. `false` disables it (cost stays `null`). Omitted → mirror the main store:
* a Lucid pricing store on the same connection when the main store is Lucid, otherwise off.
*/
async #resolvePricing(config) {
const pricingStore = config.pricingStore;
if (pricingStore === false)
return undefined;
if (pricingStore === undefined) {
const connection = this.#mainStoreLucidConnection(config);
if (connection === undefined)
return undefined;
return pricingStores.lucid(connection === null ? {} : { connection })({ app: this.app });
}
return typeof pricingStore === 'function' ? pricingStore({ app: this.app }) : pricingStore;
}
/**
* Build the governance read-model. `false` disables it (the `/agent/governance/*` routes aren't
* mounted). Omitted → mirror the main store: a Lucid read-model on the same connection when the main
* store is Lucid, otherwise off. The factory receives the already-resolved `pricingStore` so the
* Lucid read-model prices its rollups against the loop's live prices.
*/
async #resolveGovernance(config, pricingStore) {
const governance = config.governanceQueries;
if (governance === false)
return undefined;
const ctx = { app: this.app, ...(pricingStore !== undefined ? { pricingStore } : {}) };
if (governance === undefined) {
const connection = this.#mainStoreLucidConnection(config);
if (connection === undefined)
return undefined;
return governanceQueriesFactories.lucid(connection === null ? {} : { connection })(ctx);
}
return typeof governance === 'function' ? governance(ctx) : governance;
}
/** Build the inject-mode retriever from config (a `RetrieverFactory` thunk or a ready instance). */
async #resolveRetriever(config) {
const retriever = config.retriever;
if (retriever === undefined)
return undefined;
return typeof retriever === 'function' ? retriever({ app: this.app }) : retriever;
}
/**
* Build the attachment-staging store from config (an `AttachmentStagingFactory` thunk or a ready
* instance). `undefined` → no upload route is mounted (a client sends already-staged references).
*/
async #resolveAttachmentStaging(config, agentStore) {
const staging = config.attachments;
if (staging === undefined)
return undefined;
return typeof staging === 'function' ? staging({ app: this.app, agentStore }) : staging;
}
#resolveAuthorizer(config, defaultRoles) {
// Whatever the app configured, personal agents (A2A) reach only tools that name them — see
// `personalAgentGate`. A no-op for every other actor.
return personalAgentGate(config.authorizer ??
config.rolesPolicy ??
new DefaultToolAuthorizer(defaultRoles, { emptyRoles: config.emptyRoles ?? 'allow' }));
}
/**
* Build the durable runner when `durable: true`, or `null` to fall back to inline. The optional
* `@adonis-agora/durable` peer is imported lazily (so the agent package never hard-depends on it),
* its {@link WorkflowEngine} resolved from the container (bound by the durable provider), the agent
* workflow registered on it, and the module-level durable context wired. A missing peer, an
* unresolvable engine, or any wiring error logs a warning and degrades to the in-process runner
* rather than breaking boot.
*/
async #resolveDurableRunner(factory, store, queue, sink) {
try {
const durable = await import('@adonis-agora/durable');
const engine = await this.app.container.make(durable.WorkflowEngine);
const { DurableAgentRunner, registerAgentWorkflow, setDurableAgentContext } = await import('../src/durable/index.js');
// Where the engine serves dispatched steps (its transport): the text channels run their
// phases there, in whichever process takes them. Absent (an older durable, a role without
// one) → the channels run their phases in the workflow's process.
const { rememberAgentStepServer } = await import('../src/durable/agent-run-context.js');
const transportBinding = '@adonis-agora/durable:transport';
let transport;
try {
if (this.app.container.hasBinding(transportBinding))
transport = await this.app.container.make(transportBinding);
}
catch {
transport = undefined;
}
rememberAgentStepServer(typeof transport?.handle === 'function'
? transport
: undefined);
setDurableAgentContext({ factory, store, queue, engine });
registerAgentWorkflow(engine);
return new DurableAgentRunner(engine, store, queue, sink);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[@adonis-agora/agent] \`durable: true\` was requested but the durable runner could not be wired (${message}) — is \`@adonis-agora/durable\` installed and configured? Falling back to the in-process (inline) runner.`);
return null;
}
}
/** Best-effort import of the build-time tools barrel; `null` when absent (fall back to the scan). */
async #loadGeneratedToolsBarrel() {
const path = this.app.makePath('.adonisjs/agent/tools.js');
try {
const mod = (await import(pathToFileURL(path).href));
return mod.tools ?? null;
}
catch (err) {
const code = err.code;
if (code === 'ERR_MODULE_NOT_FOUND' || code === 'ENOENT')
return null;
throw err;
}
}
// ── routes ────────────────────────────────────────────────────────────────
async #registerRoutes(config, service, actorResolver, agents, attachmentStaging, governance, governanceAuthorize, pricingStore,
/** Read-side ref→label lookup; `null` when unbound, in which case rows keep their raw refs. */
actorDirectory) {
const router = await this.app.container.make('router');
const path = (config.path ?? 'agent').replace(/^\/+|\/+$/g, '');
const p = (suffix) => `${path}/${suffix}`;
// One source of attachment limits: what the store declares, else the defaults.
const declared = attachmentStaging?.describe?.() ?? {};
const attachmentLimits = {
maxBytes: declared.maxBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES,
allowedContentTypes: declared.allowedContentTypes ?? DEFAULT_ALLOWED_ATTACHMENT_CONTENT_TYPES,
};
const defaultAgentName = config.defaultAgent?.name ?? 'default';
// 1. POST /agent/chat — resolve actor, start the run, SSE-pipe the token stream. When the caller
// continues an EXISTING thread (`body.threadId`), it must own it: otherwise an authenticated caller
// could pass another actor's threadId to load that thread's full history into the model (read it
// back over SSE) and append its own turn into the victim's thread. A new-thread chat (no threadId)
// has no owner to check. Owner-scoped exactly like the `threads/:id` routes.
router.post(p('chat'), async (ctx) => {
// Read the body first so the target agent is known: an agent with its own `actorResolver`
// (e.g. one that reads the caller from the body) resolves the actor with it, else the global.
const body = (ctx.request.body() ?? {});
// A name a persona took over (`Persona.aliases`) is answered by the agent that owns it.
const named = body.agent ?? defaultAgentName;
const agentName = agents.get(named) !== undefined
? named
: (resolvePersonaAlias(agents.list(), named)?.agent ?? named);
const resolver = resolveActorResolver(actorResolver, agents.get(agentName));
const actor = await this.#resolveActor(ctx, resolver);
if (actor === null)
return;
if (body.threadId !== undefined) {
const owner = await service.threadOwner(body.threadId);
if (!(await this.#assertOwner(ctx, actor, owner, 'thread', governanceAuthorize)))
return;
}
const regenerate = body.regenerate === true;
if (regenerate && body.threadId === undefined) {
return ctx.response.badRequest({
message: 'regenerate requires an existing threadId',
code: 'thread_required',
});
}
const refs = attachmentRefs(body.attachments);
if (typeof refs === 'string') {
return ctx.response.badRequest({ message: refs });
}
const mode = sendMode(body);
if (mode === null) {
return ctx.response.badRequest({ message: "mode must be 'auto', 'queue' or 'interrupt'" });
}
let uiCapabilities;
try {
if (body.uiCapabilities !== undefined)
uiCapabilities = validateUiCapabilities(body.uiCapabilities);
}
catch {
return ctx.response.badRequest({
message: 'uiCapabilities must be valid UI capabilities',
code: 'invalid_ui_capabilities',
});
}
let started;
try {
started = await service.send({
actor,
mode,
// A regenerate answers the stored user message; whatever `message` says is ignored.
message: regenerate ? '' : body.message,
...(regenerate ? { regenerate: true } : {}),
...(body.transient === true ? { transient: true } : {}),
...(typeof body.model === 'string' && body.model.length > 0 ? { model: body.model } : {}),
...(body.threadId !== undefined ? { threadId: body.threadId } : {}),
...(body.agent !== undefined ? { agentName: body.agent } : {}),
...(typeof body.persona === 'string' && body.persona.length > 0
? { personaId: body.persona }
: {}),
...(body.pageContext !== undefined ? { pageContext: body.pageContext } : {}),
...(uiCapabilities !== undefined ? { uiCapabilities } : {}),
...(refs.length > 0 ? { attachments: refs } : {}),
...(typeof body.sentAt === 'string' || typeof body.sentAt === 'number'
? { sentAt: body.sentAt }
: {}),
});
}
catch (error) {
if (error instanceof ActionProposalServiceError)
return ctx.response.status(error.status).json({ message: error.message });
if (error instanceof AttachmentRefusedError) {
return ctx.response.status(error.status).json({ message: error.message });
}
if (error instanceof ModelNotAllowedError) {
return ctx.response.badRequest({ message: error.message, code: 'model_not_allowed' });
}
if (error instanceof PersonaNotFoundError) {
return ctx.response.badRequest({ message: error.message, code: error.code });
}
if (error instanceof RegenerateNeedsThreadError) {
return ctx.response.badRequest({ message: error.message, code: 'thread_required' });
}
if (error instanceof ChatQueueError) {
return this.#refuseQueue(ctx, error);
}
if (error instanceof QuotaBlockedError) {
return ctx.response.status(429).json({
code: 'quota_exceeded',
period: error.period,
message: error.message,
});
}
throw error;
}
if ('proposalDecision' in started)
return ctx.response.json(started);
if (started.queued === true) {
// The thread already has a turn running: the message waits in its queue (`202`, JSON — no
// stream). `runId` is there when it started straight away; attach to it with `GET …/stream`.
return ctx.response.status(202).json(started);
}
await this.#pipe(ctx, service, started.runId, started.threadId);
});
// 2. GET /agent/chat/:runId/stream — re-attach SSE. Authenticated: the actor resolver reads the
// request's session/cookies (an EventSource can't set headers), 401 on failure — so an anonymous
// caller can never re-attach to a run's live token stream.
router.get(p('chat/:runId/stream'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
const runId = String(ctx.params.runId);
const owner = await service.runOwner(runId);
if (!(await this.#assertOwner(ctx, actor, owner, 'run', governanceAuthorize)))
return;
// Nothing buffered under the run — it ended while the client was away, or its buffer went with
// a restarted process. A 404 reads as "nothing to resume"; subscribing would wait forever.
if (!(await service.hasStream(runId))) {
return ctx.response.notFound({ message: 'Nothing is streaming under that run.' });
}
// `?after=<seq>` (or the `Last-Event-ID` a browser EventSource sends on its own) skips the
// events a reconnecting client already has; `after` wins when both are present.
const after = parseStreamCursor(ctx.request.qs().after) ??
parseStreamCursor(ctx.request.header('last-event-id')) ??
0;
await this.#pipe(ctx, service, runId, undefined, after);
});
// 3. POST /agent/chat/:runId/cancel — authenticated + owner-scoped (a caller cancels only its own
// runs, unless governance-privileged).
router.post(p('chat/:runId/cancel'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
const runId = String(ctx.params.runId);
const owner = await service.runOwner(runId);
if (!(await this.#assertOwner(ctx, actor, owner, 'run', governanceAuthorize)))
return;
await service.cancel(runId);
return ctx.response.json({ aborted: true });
});
// 3b. Protocol adapters (`adapters` in the config): other wire protocols over the same runs —
// e.g. `agUiAdapter()` mounts `POST <path>/ag-ui`. Each gets the checks these routes use, so an
// adapter cannot be more permissive than the native routes by forgetting one.
// An engine's own routes (the MCP endpoint OpenCode reaches the app's tools through) mount the
// same way, after the configured adapters.
for (const adapter of [...(config.adapters ?? []), ...(this.#engine?.adapters?.() ?? [])]) {
await adapter.mount({
service,
defaultAgentName,
...(this.#genuiCatalog !== undefined ? { genuiCatalog: this.#genuiCatalog } : {}),
post: (suffix, handler) => void router.post(p(suffix), handler),
get: (suffix, handler) => void router.get(p(suffix), handler),
resolveActor: (ctx, agentName) => this.#resolveActor(ctx, agentName === undefined
? actorResolver
: resolveActorResolver(actorResolver, agents.get(agentName))),
assertOwner: (ctx, actor, ownerRef, kind) => this.#assertOwner(ctx, actor, ownerRef, kind, governanceAuthorize),
mayDecide: (ctx, actor, runId, toolCallId) => this.#mayDecide(ctx, service, actor, runId, toolCallId, governanceAuthorize),
refuseSend: (ctx, error) => this.#refuseSend(ctx, error),
conflictOnMismatch: (ctx, error) => this.#conflictOnMismatch(ctx, error),
...(attachmentStaging !== undefined
? {
attachments: {
store: attachmentStaging,
maxBytes: attachmentLimits.maxBytes,
allowedContentTypes: attachmentLimits.allowedContentTypes,
maxPerMessage: MAX_ATTACHMENTS_PER_MESSAGE,
},
}
: {}),
});
}
// 4. POST /agent/tool-call/approve — authenticated + owner-scoped. Delivering a HITL decision is a
// privileged control: only the actor that OWNS the run (or a governance-privileged actor) may
// approve its pending tool call — an authenticated non-owner who learns a runId/toolCallId cannot.
router.post(p('tool-call/approve'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
const body = (ctx.request.body() ?? {});
const remember = decisionRemember(body.remember);
const via = decisionVia(body.via);
if (remember === null || via === null) {
return ctx.response.badRequest({
message: `remember must be a boolean and via a string of 1-${MAX_VIA_LENGTH} characters`,
});
}
const runId = await this.#runOfToolCall(ctx, service, body);
if (runId === null)
return;
if (!(await this.#mayDecide(ctx, service, actor, runId, body.toolCallId, governanceAuthorize)))
return;
try {
await service.approve(runId, body.toolCallId, { executedByRef: actor.id, remember, via });
}
catch (error) {
return this.#conflictOnMismatch(ctx, error);
}
return ctx.response.json({ ok: true });
});
// 5. POST /agent/tool-call/reject — authenticated + owner-scoped (mirrors approve).
router.post(p('tool-call/reject'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
const body = (ctx.request.body() ?? {});
const via = decisionVia(body.via);
if (via === null) {
return ctx.response.badRequest({
message: `via must be a string of 1-${MAX_VIA_LENGTH} characters`,
});
}
const runId = await this.#runOfToolCall(ctx, service, body);
if (runId === null)
return;
if (!(await this.#mayDecide(ctx, service, actor, runId, body.toolCallId, governanceAuthorize)))
return;
try {
await service.reject(runId, body.toolCallId, body.reason, { executedByRef: actor.id, via });
}
catch (error) {
return this.#conflictOnMismatch(ctx, error);
}
return ctx.response.json({ ok: true });
});
// 5b. POST /agent/tool-call/answer — authenticated + owner-scoped (mirrors approve). Settles a
// parked question set with the user's answers; an omitted question takes its own pre-picked
// default, resolved server-side against the request the run already holds.
router.post(p('tool-call/answer'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
const body = (ctx.request.body() ?? {});
const via = decisionVia(body.via);
if (via === null) {
return ctx.response.badRequest({
message: `via must be a string of 1-${MAX_VIA_LENGTH} characters`,
});
}
const runId = await this.#runOfToolCall(ctx, service, body);
if (runId === null)
return;
const owner = await service.runOwner(runId);
if (!(await this.#assertOwner(ctx, actor, owner, 'run', governanceAuthorize)))
return;
// A value a question's rules refuse, or a `required` question left empty, is refused HERE so
// the person who typed it hears why — rather than signalled and quietly dropped by the loop.
const problem = await service.answerProblem(body.toolCallId, body.answers ?? {}, runId);
if (problem !== null) {
return ctx.response.badRequest({ message: problem });
}
try {
await service.answer({
runId,
toolCallId: body.toolCallId,
answers: body.answers ?? {},
answeredByRef: actor.id,
answeredVia: via,
});
}
catch (error) {
return this.#conflictOnMismatch(ctx, error);
}
return ctx.response.json({ ok: true });
});
// 5c. POST /agent/tool-call/skip — the user declining to answer. Same values as a confirmation,
// recorded as a different fact.
router.post(p('tool-call/skip'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
const body = (ctx.request.body() ?? {});
const via = decisionVia(body.via);
if (via === null) {
return ctx.response.badRequest({
message: `via must be a string of 1-${MAX_VIA_LENGTH} characters`,
});
}
const runId = await this.#runOfToolCall(ctx, service, body);
if (runId === null)
return;
const owner = await service.runOwner(runId);
if (!(await this.#assertOwner(ctx, actor, owner, 'run', governanceAuthorize)))
return;
try {
await service.skip({
runId,
toolCallId: body.toolCallId,
answeredByRef: actor.id,
answeredVia: via,
});
}
catch (error) {
return this.#conflictOnMismatch(ctx, error);
}
return ctx.response.json({ ok: true });
});
// 6. GET /agent/threads — the actor's threads.
router.get(p('threads'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
return ctx.response.json(await service.listThreads(actor.id));
});
// 6a. POST /agent/messages/:id/feedback — the thread owner rates a message. Only the OWNER: a
// rating is the owner's opinion of their own conversation, so no governance bypass here.
router.post(p('messages/:id/feedback'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
const messageId = String(ctx.params.id);
const threadId = await service.threadOfMessage(messageId);
if (threadId === undefined) {
return ctx.response.status(501).json({
message: 'Message feedback requires an AgentStore that implements threadOfMessage() and setMessageFeedback().',
});
}
if (threadId === null) {
return ctx.response.notFound({ message: 'message not found' });
}
const owner = await service.threadOwner(threadId);
if (!(await this.#assertOwner(ctx, actor, owner, 'thread', undefined)))
return;
const body = (ctx.request.body() ?? {});
const result = await service.setMessageFeedback(messageId, {
value: body.value ?? null,
...(body.comment !== undefined ? { comment: body.comment } : {}),
});
if (!result.ok) {
return ctx.response.status(result.status).json({ message: result.error });
}
return ctx.response.json({ feedback: result.feedback });
});
// 6c. GET /agent/models?agent= — the models the caller may pick, grouped by provider, with
// availability; an empty catalog when none is configured. GET /agent/agents — the registered
// agents for a picker, the default flagged.
router.get(p('models'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
const agent = ctx.request.qs().agent;
return ctx.response.json(await service.listModels(actor, agent));
});
router.get(p('agents'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
return ctx.response.json(await service.listAgents(actor));
});
// 6b. GET /agent/tools?agent= — the tools this caller can reach through an agent, with how a chat
// surface should talk about each (`presentation`). The same list the model is offered; nothing the
// caller passes widens it — `agent` only narrows to that agent's allow-list, and an unknown name is
// a 404 rather than the widest answer (no allow-list at all). `?agent=*` is the union across every
// agent: each tool this caller reaches through any of them, once, under the same gates.
router.get(p('tools'), async (ctx) => {
const agent = ctx.request.qs().agent;
if (agent === ALL_AGENTS) {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
return ctx.response.json(await service.toolCatalogForAllAgents(actor));
}
if (agent !== undefined && agent !== defaultAgentName && agents.get(agent) === undefined) {
return ctx.response.notFound({ message: `No agent named "${agent}"` });
}
const actor = await this.#resolveActor(ctx, resolveActorResolver(actorResolver, agents.get(agent ?? defaultAgentName)));
if (actor === null)
return;
return ctx.response.json(await service.toolCatalog(actor, agent));
});
// 7. GET /agent/threads/personas/catalog (before :id so it isn't captured by the param).
// Authenticated — an anonymous caller reads nothing from the agent surface.
router.get(p('threads/personas/catalog'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
return ctx.response.json(service.personaCatalog());
});
const proposalResponse = async (ctx, action) => {
try {
return ctx.response.json(await action());
}
catch (error) {
if (error instanceof ActionProposalServiceError)
return ctx.response.status(error.status).json({ message: error.message });
throw error;
}
};
router.get(p('threads/:id/action-proposals'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (!actor)
return;
return proposalResponse(ctx, async () => {
let after;
const cursor = ctx.request.input('after');
if (cursor !== undefined) {
try {
if (typeof cursor !== 'string')
throw new Error('cursor');
after = JSON.parse(cursor);
validateActionProposalListQuery({ after });
}
catch {
throw new ActionProposalServiceError(400, 'Invalid action proposal cursor');
}
}
const page = await service.listActionProposalsPage(actor, ctx.params.id, after);
if (page.next)
ctx.response.header('X-Action-Proposals-Next', encodeURIComponent(JSON.stringify(page.next)));
ctx.response.header('Access-Control-Expose-Headers', 'X-Action-Proposals-Next');
return page.items;
});
});
for (const [route, decision] of [
['approve', 'approved'],
['reject', 'rejected'],
]) {
router.post(p(`threads/:id/action-proposals/:proposalId/${route}`), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (!actor)
return;
return proposalResponse(ctx, () => service.decideActionProposal(actor, ctx.params.id, ctx.params.proposalId, decision, ctx.request.body() ?? {}));
});
}
// 8. GET /agent/threads/:id — detail or null. Authenticated + owner-scoped (a caller reads only its
// own threads, unless governance-privileged).
router.get(p('threads/:id'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
const threadId = String(ctx.params.id);
const owner = await service.threadOwner(threadId);
if (!(await this.#assertOwner(ctx, actor, owner, 'thread', governanceAuthorize)))
return;
return ctx.response.json(await service.getThread(threadId, actor));
});
// 8b. PATCH /agent/threads/:id — rename (`{ title }`), what a thread list's rename calls.
// Authenticated + owner-scoped. Unknown keys are ignored, so a client that also sends fields this
// server does not store yet is not refused for it.
router.patch(p('threads/:id'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
const threadId = String(ctx.params.id);
const owner = await service.threadOwner(threadId);
if (!(await this.#assertOwner(ctx, actor, owner, 'thread', governanceAuthorize)))
return;
const body = (ctx.request.body() ?? {});
if (body.model !== undefined && body.model !== null && typeof body.model !== 'string') {
return ctx.response.badRequest({ message: 'model must be a string or null' });
}
if (body.defaultAgent !== undefined &&
body.defaultAgent !== null &&
typeof body.defaultAgent !== 'string') {
return ctx.response.badRequest({ message: 'defaultAgent must be a string or null' });
}
if (body.persona !== undefined && body.persona !== null && typeof body.persona !== 'string') {
return ctx.response.badRequest({ message: 'persona must be a string or null' });
}
if (body.model !== undefined ||
body.defaultAgent !== undefined ||
body.persona !== undefined) {
try {
const saved = await service.updateThreadSettings(actor, threadId, {
...(body.model !== undefined ? { model: body.model } : {}),
...(body.defaultAgent !== undefined
? { defaultAgent: body.defaultAgent }
: {}),
...(body.persona !== undefined ? { persona: body.persona } : {}),
});
if (!saved) {
return ctx.response.status(501).json({
message: "Setting a thread's model, default agent or persona requires an AgentStore that implements updateThread().",
});
}
}
catch (error) {
if (error instanceof ModelNotAllowedError || error instanceof UnknownAgentError) {
return ctx.response.badRequest({ message: error.message });
}
if (error instanceof PersonaNotFoundError) {
return ctx.response.badRequest({ message: error.message, code: error.code });
}
throw error;
}
}
if (body.title !== undefined) {
const title = typeof body.title === 'string' ? body.title.trim() : '';
if (title.length === 0 || title.length > 200) {
return ctx.response.badRequest({ message: 'title must be a string of 1-200 characters' });
}
await service.renameThread(threadId, title);
}
return ctx.response.json({ ok: true });
});
// 8d. The thread message queue — messages sent while a turn was running, waiting to run after
// it. Owner-scoped like the thread routes; each answers the queue (`{ items, paused }`) and
// publishes it into the stream of the run holding the thread. `501` on a store without a queue.
router.get(p('threads/:id/queue'), async (ctx) => {
const threadId = await this.#ownedThread(ctx, service, actorResolver, governanceAuthorize);
if (threadId === null)
return;
return this.#queueAnswer(ctx, () => service.getQueue(threadId));
});
router.delete(p('threads/:id/queue'), async (ctx) => {
const threadId = await this.#ownedThread(ctx, service, actorResolver, governanceAuthorize);
if (threadId === null)
return;
return this.#queueAnswer(ctx, () => service.clearQueue(threadId));
});
router.post(p('threads/:id/queue/resume'), async (ctx) => {
const threadId = await this.#ownedThread(ctx, service, actorResolver, governanceAuthorize);
if (threadId === null)
return;
return this.#queueAnswer(ctx, () => service.resumeQueue(threadId));
});
router.patch(p('queue/:messageId'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
const messageId = String(ctx.params.messageId);
if (!(await this.#ownsQueuedMessage(ctx, service, actor, messageId, governanceAuthorize)))
return;
const body = (ctx.request.body() ?? {});
if (body.message !== undefined && typeof body.message !== 'string') {
return ctx.response.badRequest({ message: 'message must be a string' });
}
if (body.position !== undefined && typeof body.position !== 'number') {
return ctx.response.badRequest({ message: 'position must be a non-negative integer' });
}
const refs = body.attachments === undefined || body.attachments === null
? body.attachments
: attachmentRefs(body.attachments);
if (typeof refs === 'string') {
return ctx.response.badRequest({ message: refs });
}
return this.#queueAnswer(ctx, () => service.updateQueuedMessage(actor, messageId, {
...(typeof body.message === 'string' ? { message: body.message } : {}),
...(refs !== undefined ? { attachments: refs } : {}),
...(typeof body.position === 'number' ? { position: body.position } : {}),
}));
});
// Run a waiting message now — the "send this one now" button on a queued bubble. Answers the
// queue plus `interrupting` (the run it cancelled) or `runId` (nothing was running; it started).
router.post(p('queue/:messageId/interrupt'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
const messageId = String(ctx.params.messageId);
if (!(await this.#ownsQueuedMessage(ctx, service, actor, messageId, governanceAuthorize)))
return;
return this.#queueAnswer(ctx, () => service.interruptQueuedMessage(messageId));
});
router.delete(p('queue/:messageId'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
const messageId = String(ctx.params.messageId);
if (!(await this.#ownsQueuedMessage(ctx, service, actor, messageId, governanceAuthorize)))
return;
return this.#queueAnswer(ctx, () => service.removeQueuedMessage(messageId));
});
// 9. DELETE /agent/threads/:id. Authenticated + owner-scoped.
router.delete(p('threads/:id'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
const threadId = String(ctx.params.id);
const owner = await service.threadOwner(threadId);
if (!(await this.#assertOwner(ctx, actor, owner, 'thread', governanceAuthorize)))
return;
await service.deleteThread(threadId);
return ctx.response.json({ ok: true });
});
// 10. POST /agent/threads/:id/fork-from/:messageId. Authenticated + owner-scoped.
router.post(p('threads/:id/fork-from/:messageId'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
const threadId = String(ctx.params.id);
const owner = await service.threadOwner(threadId);
if (!(await this.#assertOwner(ctx, actor, owner, 'thread', governanceAuthorize)))
return;
return ctx.response.json(await service.forkThread(threadId, String(ctx.params.messageId)));
});
// 10b. POST /agent/threads/:id/promote — keep a transient thread (it joins `GET threads`).
router.post(p('threads/:id/promote'), async (ctx) => {
const threadId = await this.#ownedThread(ctx, service, actorResolver, governanceAuthorize);
if (threadId === null)
return;
if (!(await service.promoteThread(threadId))) {
return ctx.response.status(501).json({
message: 'Promoting a thread requires an AgentStore that implements promoteThread().',
});
}
return ctx.response.json({ ok: true });
});
// 10c. DELETE /agent/threads/:id/from/:messageId — drop a message and everything after it, the
// "edit and resend" primitive.
router.delete(p('threads/:id/from/:messageId'), async (ctx) => {
const threadId = await this.#ownedThread(ctx, service, actorResolver, governanceAuthorize);
if (threadId === null)
return;
await service.truncateThreadFrom(threadId, String(ctx.params.messageId));
return ctx.response.json({ ok: true });
});
// 10d. GET /agent/skills?threadId= — the skills THIS caller can invoke right now, scope-resolved:
// the same list the model is offered, built by the same call, so what a composer suggests after
// a `/` and what the agent can reach cannot drift apart. No skills configured → an empty list.
// `threadId` only ever reaches the host's own resolver and provider; nothing here reads the
// thread, so an unknown id widens nothing.
const skills = config.skills;
router.get(p('skills'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
if (skills === undefined)
return ctx.response.json([]);
const threadId = ctx.request.qs().threadId;
const offer = await offerSkills(skills, {
actor,
threadId: typeof threadId === 'string' ? threadId : '',
});
return ctx.response.json(offer.entries);
});
// 11. GET /agent/memories + DELETE /agent/memories/:id — what the assistant believes about THIS
// caller, and how they take one of those beliefs back.
//
// WHY THIS EXISTS WHERE SKILLS HAVE NO SUCH SIBLING. A skill is authored by a person who already
// knows it exists; a memory is written by the agent, about someone who does not. So the
// read-back is not a convenience on top of the feature, it IS half of the feature: a belief
// nobody can inspect is one nobody can correct, and a belief nobody can delete is one the
// deployment keeps whether or not it is true. That is also why `MemoryProvider.forget` is
// required rather than optional.
const memory = config.memory;
// DELIBERATELY IGNORES `maxMemories`. That ceiling is a budget on what one TURN carries;
// applying it here would mean a person could not see — and so could not delete — a belief the
// assistant is one write away from acting on again. Showing someone more than the model sees is
// harmless; showing them less is the failure this endpoint exists to prevent. For the same
// reason it never passes a query: a relevance selection is what a turn wants, not what a person
// owed the whole picture wants.
const everyMemoryOf = (actor) => offerMemories({
config: { ...memory, maxMemories: Number.POSITIVE_INFINITY },
ctx: { actor, threadId: '' },
});
router.get(p('memories'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
if (memory === undefined)
return ctx.response.json([]);
return ctx.response.json((await everyMemoryOf(actor)).entries);
});
// Authorized against the actor's OWN resolved list, the same way a `skill` load is authorized
// against the turn's catalog: an id this actor cannot see is answered as missing rather than
// refused, so the endpoint cannot be used to find out which memories exist about other people.
router.delete(p('memories/:id'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
if (memory === undefined) {
return ctx.response.notFound({ message: 'No memory is configured in this deployment.' });
}
const id = String(ctx.params.id);
const entry = (await everyMemoryOf(actor)).entries.find((candidate) => candidate.id === id);
if (entry === undefined) {
return ctx.response.notFound({ message: `No memory with id "${id}".` });
}
const verdict = memoryForgetVerdict({ record: entry, actor });
if (!verdict.allowed) {
return ctx.response.forbidden({ message: verdict.reason });
}
return ctx.response.json({
forgotten: await memory.provider.forget({ id: entry.id, ctx: { actor, threadId: '' } }),
});
});
// 11a. GET /agent/config — server facts a client would otherwise repeat (attachment limits, whether
// a picker / quota / anonymous identity is in play). Resolves the actor like every route, so an
// anonymous browser gets its identity on whichever request comes first.
router.get(p('config'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
const clientConfig = {
attachments: {
enabled: attachmentStaging !== undefined,
upload: attachmentStaging === undefined ? null : (declared.upload ?? 'multipart'),
maxBytes: attachmentLimits.maxBytes,
allowedContentTypes: attachmentLimits.allowedContentTypes,
maxPerMessage: MAX_ATTACHMENTS_PER_MESSAGE,
},
models: { enabled: service.hasModelCatalog() },
quota: { enforced: config.quota !== undefined },
identity: { anonymous: actorResolver instanceof AnonymousActorResolver },
...(this.#sandboxClient !== undefined ? { genui: { sandbox: this.#sandboxClient() } } : {}),
};
return ctx.response.json(clientConfig);
});
// 11b. GET /agent/quota — every budget window with what was spent in it, and `blocked` naming
// the exhausted one.
router.get(p('quota'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
return ctx.response.json(await service.quotaReport(actor));
});
// 12. POST /agent/attachments — OPTIONAL. Mounted only when `attachments` is configured, so
// an app without it never exposes an upload surface. Buffers the multipart `file` field, validates
// it against the size cap + content-type allowlist, then stages it into a model-fetchable
// MessageAttachment the client sends back on its next `chat` call. Mirrors the SSE routes' envelope
// (JSON body, actor resolved via the shared resolver, precise HTTP status on rejection).
if (attachmentStaging !== undefined) {
const staging = attachmentStaging;
const { maxBytes, allowedContentTypes } = attachmentLimits;
router.post(p('attachments'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
const file = ctx.request.file('file');
if (file === null || file.tmpPath === undefined) {
return ctx.response.status(400).json({ message: 'multipart field "file" is required' });
}
const contentType = file.headers?.['content-type']?.split(';')[0]?.trim() ?? `${file.type}/${file.subtype}`;
if (!allowedContentTypes.includes(contentType)) {
return ctx.response.status(415).json({
message: `content type "${contentType}" is not allowed (allowed: ${allowedContentTypes.join(', ')})`,
});
}
const sizeBytes = file.size;
if (sizeBytes > maxBytes) {
return ctx.response
.status(413)
.json({ message: `file exceeds the ${maxBytes}-byte limit` });
}
const { readFile } = await import('node:fs/promises');
const data = await readFile(file.tmpPath);
const attachment = await staging.stage({
data,
filename: file.clientName,
contentType,
sizeBytes,
actor,
});
return ctx.response.json(attachment);
});
// 12a. GET /agent/attachments — the caller's own staged files, newest first, metadata only
// (no url: one is minted per request by the store's `resolve`). The one question
// `GET threads/:id` cannot answer, since an upload that was never sent belongs to no thread.
// `501` when the store keeps no inventory (`list`) — never an empty list in its place.
// Collection is NOT a route: a sweep needs a host-chosen age and ends in deleting bytes, so it
// stays in-process (`AgentService.collectableAttachments`).
router.get(p('attachments'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
try {
return ctx.response.json(await service.listAttachments(actor, { limit: ATTACHMENT_PAGE_SIZE }));
}
catch (error) {
if (error instanceof AttachmentInventoryError) {
return ctx.response.status(error.status).json({ message: error.message });
}
throw error;
}
});
}
// 12b. Resumable (tus) attachment uploads — mounted only when the store serves them
// (`attachmentStores.media()` over a media library with `uploads.resumable`). These routes never
// carry a byte: they open an owned, validated tus session, confirm the bytes arrived, and drop an
// attachment the user removed. The bytes go to `@adonis-agora/media`'s own tus routes.
const resumable = resumableStaging(attachmentStaging);
if (resumable !== undefined) {
const answer = async (ctx, work) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
try {
return await work(actor);
}
catch (error) {
const status = error.status;
if (typeof status === 'number' && status >= 400 && status < 600) {
return ctx.response
.status(status)
.json({ message: error instanceof Error ? error.message : String(error) });
}
throw error;
}
};
// `{ filename, contentType, size }` → `{ mediaId, uploadId, location }`.
router.post(p('attachments/uploads'), (ctx) => answer(ctx, async (actor) => {
const body = ctx.request.body();
return ctx.response.json(await resumable.beginUpload({
actor,
filename: body.filename,
contentType: body.contentType,
size: body.size,
}));
}));
router.post(p('attachments/uploads/:mediaId/complete'), (ctx) => answer(ctx, async (actor) => {
const mediaId = String(ctx.params.mediaId);
const attachment = await resumable.completeUpload({ actor, mediaId });
if (attachment === null) {
return ctx.response.status(404).json({ message: `attachment ${mediaId} not found` });
}
return ctx.response.json(attachment);
}));
router.delete(p('attachments/uploads/:mediaId'), (ctx) => answer(ctx, async (actor) => {
const mediaId = String(ctx.params.mediaId);
if (!(await resumable.discard({ actor, mediaId }))) {
return ctx.response.status(404).json({ message: `attachment ${mediaId} not found` });
}
return ctx.response.status(204).send('');
}));
}
// The governance read-model resolved (often by default, when the main store is Lucid) but no
// authorization gate was configured. The cross-actor `/agent/governance/*` routes are therefore
// NOT mounted at all (they 404) — fail closed by omission, because an ungated cross-actor
// read-model exposes every actor's spend/usage/threads/approvals to any authenticated caller,
// and a boot warning is not a control. `/agent/approvals/mine` is unaffected (see below).
if (governance !== undefined && governanceAuthorize === undefined) {
console.warn('[@adonis-agora/agent] `/agent/governance/*` was NOT mounted: the cross-actor spend/usage/threads/approvals read-model requires a `governanceAuthorize` gate, and none is configured. Set `governanceAuthorize` in config/agent.ts (e.g. an ADMIN check) to mount the routes gated, or `governanceAuthorize: () => true` to deliberately restore the old behaviour of letting ANY authenticated actor read them. `GET /agent/approvals/mine` is unaffected — it stays mounted and scoped to the calling actor.');
}
// ── Per-actor approvals inbox. Mounted with the governance read-model (it reads the same store),
// but NOT behind `governanceAuthorize`: it is ALWAYS scoped to the calling actor's OWN pending
// approvals (`pendingApprovals({ actor })` filters by the owning run's `actor_ref`). This is the
// route a non-admin surface (e.g. a coordinator's chat) polls to discover its own suspended
// tool calls, even when the cross-actor governance read-model below is ADMIN-only.
if (governance !== undefined) {
const gov = governance;
const limitFor = (ctx) => {
const raw = Number.parseInt(String(ctx.request.input('limit', '50')), 10);
const value = Number.isFinite(raw) && raw > 0 ? raw : 50;
return Math.min(value, 200);
};
// GET /agent/approvals/mine — the authenticated actor's own pending HITL approvals, oldest first.
router.get(p('approvals/mine'), async (ctx) => {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return;
return ctx.response.json(await withActorLabels(await gov.pendingApprovals({ limit: limitFor(ctx), actor: actor.id }), actorDirectory));
});
}
// ── OPTIONAL cross-actor governance read routes. Mounted only when `governanceQueries` is
// configured AND a `governanceAuthorize` gate exists — no gate means the routes do not exist at
// all (404), because every one of them reads PLATFORM-WIDE data (no route below is scoped to the
// caller). Fail closed by omission: an app that wants the historical open behaviour asks for it
// explicitly with `governanceAuthorize: () => true`. All read-only (GET). Each route resolves the
// actor (401 on failure) and then runs the gate (403 on deny) via `#resolveGovernanceActor` —
// restricting this read-model (e.g. ADMIN-only) never locks the per-actor `approvals/mine` route
// above. `from`/`to` are inclusive UTC days (`YYYY-MM-DD`), defaulting to today; `limit` defaults
// to 50, clamped to 200 (mirroring the dashboard's own clamp).
if (governance !== undefined && governanceAuthorize !== undefined) {
const gov = governance;
const g = (suffix) => p(`governance/${suffix}`);
const range = (ctx) => {
const today = new Date().toISOString().slice(0, 10);
const from = ctx.request.input('from', today);
const to = ctx.request.input('to', today);
return { fromDay: String(from), toDay: String(to) };
};
const positiveIntInput = (ctx, key) => {
const raw = Number.parseInt(String(ctx.request.input(key, '50')), 10);
const value = Number.isFinite(raw) && raw > 0 ? raw : 50;
return Math.min(value, 200);
};
/** `?limit=` — the cap on the top-N feeds (`recentToolCalls`, `recentThreads`, the approvals
* inbox). Those are capped lists, not cursor-paginated surfaces, so they keep `limit`. */
const limitOf = (ctx) => positiveIntInput(ctx, 'limit');
/** `?first=` — the page size of the cursor-paginated surfaces, which speak `{ after, first }`
* (the ecosystem interface mirroring `@adonis-agora/filter`'s `CursorParams`). Same default
* (50) and ceiling (200) as `?limit=`. */
const firstOf = (ctx) => positiveIntInput(ctx, 'first');
// GET /agent/governance/spend/model — per-model token + cost rollup over the range.
router.get(g('spend/model'), async (ctx) => {
const actor = await this.#resolveGovernanceActor(ctx, actorResolver, governanceAuthorize);
if (actor === null)
return;
return ctx.response.json(await gov.spendByModel(range(ctx)));
});
// GET /agent/governance/spend/actor — per-actor token + cost rollup over the range.
router.get(g('spend/actor'), async (ctx) => {
const actor = await this.#resolveGovernanceActor(ctx, actorResolver, governanceAuthorize);
if (actor === null)
return;
return ctx.response.json(await withActorLabels(await gov.spendByActor(range(ctx)), actorDirectory));
});
// GET /agent/governance/usage/trend — the daily token + cost trend over the range.
router.get(g('usage/trend'), async (ctx) => {
const actor = await this.#resolveGovernanceActor(ctx, actorResolver, governanceAuthorize);
if (actor === null)
return;
return ctx.response.json(await gov.usageTrend(range(ctx)));
});
// GET /agent/governance/tool-calls/recent — newest-first recent tool-call activity feed.
router.get(g('tool-calls/recent'), async (ctx) => {
const actor = await this.#resolveGovernanceActor(ctx, actorResolver, governanceAuthorize);
if (actor === null)
return;
return ctx.response.json(await gov.recentToolCalls(limitOf(ctx)));
});
// GET /agent/governance/threads/recent — newest-first recent thread activity feed.
router.get(g('threads/recent'), async (ctx) => {
const actor = await this.#resolveGovernanceActor(ctx, actorResolver, governanceAuthorize);
if (actor === null)
return;
return ctx.response.json(await withActorLabels(await gov.recentThreads(limitOf(ctx)), actorDirectory));
});
// GET /agent/governance/threads/:id — one thread's governance drill-down (metadata + lifetime
// usage rollup + recent runs/messages), or `null` when unknown. `501` when the bound governance
// adapter predates `threadDetail` (an optional SPI method — a third-party adapter may omit it).
router.get(g('threads/:id'), async (ctx) => {
const actor = await this.#resolveGovernanceActor(ctx, actorResolver, governanceAuthorize);
if (actor === null)
return;
if (gov.threadDetail === undefined) {
return ctx.response
.status(501)
.json({ message: 'this governance adapter does not support thread detail' });
}
const detail = await withActorLabel(await gov.threadDetail(String(ctx.params.id)), actorDirectory);
if (detail === null)
return ctx.response.json(null);
return ctx.response.json({
...detail,
runs: await withActorLabels(detail.runs, actorDirectory),
});
});
// GET /agent/governance/genui — generative-UI telemetry over the range (first-try success by
// tool and model, common errors, UI cost per turn/day, time to first/complete frame). `501`
// when the bound governance adapter does not record genui telemetry (optional SPI method).
router.get(g('genui'), async (ctx) => {
const actor = await this.#resolveGovernanceActor(ctx, actorResolver, governanceAuthorize);
if (actor === null)
return;
if (gov.genuiStats === undefined) {
return ctx.response
.status(501)
.json({ message: 'this governance adapter does not support generative UI telemetry' });
}
return ctx.response.json(await gov.genuiStats(range(ctx)));
});
// ── Run lifecycle governance (the run tracking read-model). Same read-only + authenticated +
// authorized envelope; the runs are cross-actor governance data.
const optionalRange = (ctx) => {
const from = ctx.request.input('from');
const to = ctx.request.input('to');
return {
...(from !== undefined ? { from: String(from) } : {}),
...(to !== undefined ? { to: String(to) } : {}),
};
};
// GET /agent/governance/runs — filterable, cursor-paginated run list, newest-first.
// Query: actor?, agent?, status?, from?, to?, after?, first?
// `after`/`first` are the ecosystem's forward-only cursor pagination interface (mirroring
// `@adonis-agora/filter`'s `CursorParams`); the body is a `CursorPage` — `items`, `nextCursor`,
// and the constant `prevCursor: null` / `hasPrev: false`.
router.get(g('runs'), async (ctx) => {
const actor = await this.#resolveGovernanceActor(ctx, actorResolver, governanceAuthorize);
if (actor === null)
return;
const filterActor = ctx.request.input('actor');
const agent = ctx.request.input('agent');
const status = ctx.request.input('status');
const after = ctx.request.input('after');
const { from, to } = optionalRange(ctx);
const page = await gov.listRuns({
first: firstOf(ctx),
...(filterActor !== undefined ? { actor: String(filterActor) } : {}),
...(agent !== undefined ? { agent: String(agent) } : {}),
...(status !== undefined ? { status: String(status) } : {}),
...(after !== undefined ? { after: String(after) } : {}),
...(from !== undefined ? { from } : {}),
...(to !== undefined ? { to } : {}),
});
return ctx.response.json({
...page,
items: await withActorLabels(page.items, actorDirectory),
});
});
// GET /agent/governance/runs/:id — one run's full trace (run + messages + tool calls +
// approvals + usage), or null.
router.get(g('runs/:id'), async (ctx) => {
const actor = await this.#resolveGovernanceActor(ctx, actorResolver, governanceAuthorize);
if (actor === null)
return;
const detail = await gov.runDetail(String(ctx.params.id));
if (detail === null)
return ctx.response.json(null);
return ctx.response.json({
...detail,
run: (await withActorLabel(detail.run, actorDirectory)) ?? detail.run,
});
});
// GET /agent/governance/approvals/pending — cross-actor HITL approvals inbox, oldest first. This
// is the platform-wide inbox (every actor's pending calls); it is governance-gated. A caller that
// only needs its OWN pending approvals uses `GET /agent/approvals/mine` instead.
router.get(g('approvals/pending'), async (ctx) => {
const actor = await this.#resolveGovernanceActor(ctx, actorResolver, governanceAuthorize);
if (actor === null)
return;
const filterActor = ctx.request.input('actor');
return ctx.response.json(await withActorLabels(await gov.pendingApprovals({
limit: limitOf(ctx),
...(filterActor !== undefined ? { actor: String(filterActor) } : {}),
}), actorDirectory));
});
// GET /agent/governance/tools/stats — per-tool call/failure/rejection/latency rollup.
router.get(g('tools/stats'), async (ctx) => {
const actor = await this.#resolveGovernanceActor(ctx, actorResolver, governanceAuthorize);
if (actor === null)
return;
return ctx.response.json(await gov.perToolStats(optionalRange(ctx)));
});
// GET /agent/governance/reliability — success/failure/cancel rates + mean settled duration.
router.get(g('reliability'), async (ctx) => {
const actor = await this.#resolveGovernanceActor(ctx, actorResolver, governanceAuthorize);
if (actor === null)
return;
return ctx.response.json(await gov.runReliability(optionalRange(ctx)));
});
// ── Model pricing CRUD — OPTIONAL within the already-optional governance block: mounted only
// when a pricing store is ALSO bound (`pricingStore: false` disables it). The store already
// exists and is wired into the loop's cost fold (`#resolvePricing`); these two routes are the
// only thing missing for the console's Pricing panel to read/write it, gated the same as every
// other cross-actor governance surface.
if (pricingStore !== undefined) {
const pricing = pricingStore;
// GET /agent/governance/pricing — every model's current per-1M rates.
router.get(g('pricing'), async (ctx) => {
const actor = await this.#resolveGovernanceActor(ctx, actorResolver, governanceAuthorize);
if (actor === null)
return;
return ctx.response.json(await pricing.listCurrentPrices());
});
// POST /agent/governance/pricing — upsert one model's rate (atomic supersede; see
// `AgentPricingStore.upsertModelPrice`). Body: `{modelId, inputPricePer1m, outputPricePer1m,
// cacheWritePricePer1m?, cacheReadPricePer1m?}`.
router.post(g('pricing'), async (ctx) => {
const actor = await this.#resolveGovernanceActor(ctx, actorResolver, governanceAuthorize);
if (actor === null)
return;
const body = (ctx.request.body() ?? {});
if (typeof body.modelId !== 'string' ||
body.modelId.length === 0 ||
typeof body.inputPricePer1m !== 'number' ||
typeof body.outputPricePer1m !== 'number') {
return ctx.response.status(400).json({
message: 'body must be {modelId: string, inputPricePer1m: number, outputPricePer1m: number, cacheWritePricePer1m?: number, cacheReadPricePer1m?: number}',
});
}
await pricing.upsertModelPrice({
modelId: body.modelId,
inputPricePer1m: body.inputPricePer1m,
outputPricePer1m: body.outputPricePer1m,
...(typeof body.cacheWritePricePer1m === 'number'
? { cacheWritePricePer1m: body.cacheWritePricePer1m }
: {}),
...(typeof body.cacheReadPricePer1m === 'number'
? { cacheReadPricePer1m: body.cacheReadPricePer1m }
: {}),
});
return ctx.response.json({ ok: true });
});
}
}
}
/**
* Resolve the actor; on failure reply 401 and return `null` so the handler short-circuits. A
* resolver is contracted to THROW when it can't establish an identity, but a misbehaving custom
* resolver that returns a nullish value instead is also treated as unauthorized (401) rather than
* letting `undefined`/`null` reach the service as a fabricated actor.
*/
async #resolveActor(ctx, actorResolver) {
try {
const actor = await actorResolver.resolve(ctx);
if (actor === null || actor === undefined) {
ctx.response.status(401).json({ message: 'unauthorized', code: 'unauthorized' });
return null;
}
return actor;
}
catch (error) {
// The resolver's `.message` is meant for the developer wiring `actorResolver`, not the caller —
// in production, every request here is untrusted by definition, so the detail stays server-side
// (dev/test keeps it, matching `evaluateDashboardGate`/`evaluateGovernanceGate`'s `debug` knob).
const debug = !this.app.inProduction;
ctx.response.status(401).json({
message: debug && error instanceof Error ? error.message : 'unauthorized',
code: 'unauthorized',
});
return null;
}
}
/**
* Resolve the actor for a cross-actor governance route, then apply the optional `governanceAuthorize`
* gate. Replies `401` (unresolved actor) or `403` (gate denied/threw) and returns `null` so the
* handler short-circuits; otherwise returns the resolved actor. The gate decision itself lives in the
* router-free {@link evaluateGovernanceGate} so it can be unit tested.
*/
async #resolveGovernanceActor(ctx, actorResolver, governanceAuthorize) {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return null;
const verdict = await evaluateGovernanceGate(actor, ctx, governanceAuthorize, !this.app.inProduction);
if (!verdict.ok) {
ctx.response.status(verdict.status).json({ message: verdict.error });
return null;
}
return actor;
}
/**
* Object-level authorization: assert the already-resolved `actor` may act on a per-actor resource
* (a run or thread) whose owning ref is `ownerRef`. A caller may act on a resource it OWNS; a
* cross-actor caller is allowed only when it is governance-privileged (passes `governanceAuthorize`,
* the app's "may act across actors" seam — so with no gate configured, ownership is strict). Replies
* `404` (unknown resource — never confirms an id the caller doesn't own) or `403` (not owner, not
* privileged) and returns `false`; returns `true` to proceed. Decision core is the router-free
* {@link evaluateOwnership}.
*/
async #assertOwner(ctx, actor, ownerRef, kind, governanceAuthorize) {
let privileged = false;
if (governanceAuthorize !== undefined) {
privileged = (await evaluateGovernanceGate(actor, ctx, governanceAuthorize, !this.app.inProduction)).ok;
}
const verdict = evaluateOwnership(actor.id, ownerRef, privileged);
if (!verdict.ok) {
const error = verdict.status === 404 ? `${kind} not found` : 'forbidden';
ctx.response.status(verdict.status).json({ message: error });
return false;
}
return true;
}
/**
* Answers addressed at a tool call that is waiting for an approve/reject are a `409`, not a `500`:
* nothing is broken, the caller sent the wrong kind of reply for that call. The run is left parked
* on the approval it was always waiting for, rather than recording a decision nobody made.
*/
/**
* The run a HITL decision addresses. `runId` is optional on the body: the shared React client
* (`@dudousxd/nestjs-agent-react`) names the call alone, since a tool call id is unique. Absent, it
* is read off the call's own row; an unknown call answers `404` and returns `null`. A supplied
* `runId` is trusted only as far as the owner check that follows it, exactly as before.
*/
async #runOfToolCall(ctx, service, body) {
if (typeof body.runId === 'string' && body.runId.length > 0)
return body.runId;
const runId = typeof body.toolCallId === 'string' ? await service.toolCallRun(body.toolCallId) : null;
if (runId === null) {
ctx.response.notFound({ message: 'Unknown tool call.' });
return null;
}
return runId;
}
/**
* May `actor` settle this call, and is it still open? The call's recorded approver decides who:
* the requester (the default, and every call recorded before approvers existed) is the run's own
* actor — the ownership check this always was, governance-privileged callers included; any other
* approver goes through the policy's `canDecide` (by default: holds that role) → `403`. A request
* that already lapsed — recorded `expired`, or past its `expiresAt` with the run's own timer about
* to fire — answers `410`: signalling it anyway would race the timeout, and a late approval must
* not be what a later replay of the run reads back. Answers the response itself when it refuses.
*/
async #mayDecide(ctx, service, actor, runId, toolCallId, governanceAuthorize) {
const approval = await service.toolCallApproval(toolCallId, runId);
const approver = approval?.approver ?? REQUESTER_APPROVER;
const owner = await service.runOwner(runId);
if (approver === REQUESTER_APPROVER) {
if (!(await this.#assertOwner(ctx, actor, owner, 'run', governanceAuthorize)))
return false;
}
else {
if (owner === null) {
ctx.response.notFound({ message: 'Unknown run.' });
return false;
}
if (!(await service.mayDecide(actor, { toolCallId, approver, requesterRef: owner }))) {
ctx.response.forbidden({ message: `This approval is for ${approver}.` });
return false;
}
}
const lapsed = approval !== null &&
(approval.status === 'expired' ||
(approval.status === 'pending_approval' &&
approval.expiresAt !== null &&
Date.parse(approval.expiresAt) <= Date.now()));
if (lapsed) {
ctx.response.gone({
message: `The approval request for tool call ${toolCallId} has expired.`,
});
return false;
}
return true;
}
/** A refused queue request: the error's own status, as `{ message, code? }`. */
#refuseQueue(ctx, error) {
ctx.response
.status(error.status)
.json({ message: error.message, ...(error.code !== undefined ? { code: error.code } : {}) });
}
/** Answer a queue route: the service's result, or the refusal it threw. */
async #queueAnswer(ctx, work) {
try {
ctx.response.json(await work());
}
catch (error) {
if (error instanceof ChatQueueError) {
return this.#refuseQueue(ctx, error);
}
if (error instanceof AttachmentRefusedError) {
ctx.response.status(error.status).json({ message: error.message });
return;
}
throw error;
}
}
/** The `:id` thread once the caller is shown to own it; `null` after replying 401/403/404. */
async #ownedThread(ctx, service, actorResolver, governanceAuthorize) {
const actor = await this.#resolveActor(ctx, actorResolver);
if (actor === null)
return null;
const threadId = String(ctx.params.id);
const owner = await service.threadOwner(threadId);
return (await this.#assertOwner(ctx, actor, owner, 'thread', governanceAuthorize))
? threadId
: null;
}
/**
* Does `actor` own the thread a queued message waits on? Replies `404` for a message that does
* not exist (or a store without a queue answers `501`), `403` for another actor's.
*/
async #ownsQueuedMessage(ctx, service, actor, messageId, governanceAuthorize) {
let threadId;
try {
threadId = await service.queuedMessageThread(messageId);
}
catch (error) {
if (error instanceof ChatQueueError) {
this.#refuseQueue(ctx, error);
return false;
}
throw error;
}
if (threadId === null) {
ctx.response.notFound({ message: `queued message ${messageId} not found` });
return false;
}
const owner = await service.threadOwner(threadId);
return this.#assertOwner(ctx, actor, owner, 'thread', governanceAuthorize);
}
/** The default `onPresentationError`: a warning on the app's logger (none bound → `undefined`). */
async #logPresentationErrors() {
if (!this.app.container.hasBinding('logger'))
return undefined;
const logger = await this.app.container.make('logger');
return (error, details) => {
logger.warn({ err: error, ...details }, 'Tool presentation failed after successful execution');
};
}
#conflictOnMismatch(ctx, error) {
// The run the decision was for has ended: nothing is waiting for it (`409 run_not_active`).
if (error instanceof RunNotActiveError) {
ctx.response.conflict({ code: error.code, message: error.message });
return;
}
if (!(error instanceof HumanReplyMismatchError)) {
throw error;
}
ctx.response.conflict({ message: error.message });
}
/**
* Answer a refused send — an attachment, a model, a busy thread, an exhausted budget — the way
* `POST <path>/chat` does. `false` for an error that is none of those (the caller rethrows).
*/
#refuseSend(ctx, error) {
if (error instanceof ActionProposalServiceError) {
ctx.response.status(error.status).json({ message: error.message });
return true;
}
if (error instanceof AttachmentRefusedError) {
ctx.response.status(error.status).json({ message: error.message });
return true;
}
if (error instanceof ModelNotAllowedError) {
ctx.response.badRequest({ message: error.message, code: 'model_not_allowed' });
return true;
}
if (error instanceof PersonaNotFoundError) {
ctx.response.badRequest({ message: error.message, code: error.code });
return true;
}
if (error instanceof ChatQueueError) {
this.#refuseQueue(ctx, error);
return true;
}
if (error instanceof QuotaBlockedError) {
ctx.response.status(429).json({
code: 'quota_exceeded',
period: error.period,
message: error.message,
});
return true;
}
return false;
}
/**
* Pipe the run's live token stream to the client as SSE: `event: meta` (runId/threadId), the run's
* frames, then `event: done`. Sets the `X-Agent-Run-Id` / `X-Agent-Thread-Id` headers. Writes the
* raw Node response directly (Adonis has no SSE helper) and ends only on stream completion — the
* sink closes on run finish, not on suspend.
*
* The sink carries typed `StreamFrame`s; each becomes the `AgentStreamEvent`s it stands for
* (`AgentSseEncoder`, the protocol shared with `@dudousxd/nestjs-agent`), and a failure ends the
* stream with `event: error` instead of `done`.
*/
async #pipe(ctx, service, runId, threadId, after = 0) {
const raw = ctx.response.response;
const headers = {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
'X-Agent-Run-Id': runId,
...(threadId !== undefined ? { 'X-Agent-Thread-Id': threadId } : {}),
};
// Headers the request already set on Adonis's response (the anonymous identity cookie, a
// session) — `writeHead` on the raw response would otherwise skip them.
const pending = ctx.response.getHeaders();
for (const [name, value] of Object.entries(pending)) {
if (value !== undefined && !(name in headers))
raw.setHeader(name, value);
}
raw.writeHead(200, headers);
raw.write(`event: meta\ndata: ${JSON.stringify({ runId, threadId })}\n\n`);
const encoder = new AgentSseEncoder(after);
for await (const frame of service.subscribe(runId)) {
const text = encoder.encode(frame);
if (text.length > 0)
raw.write(text);
}
raw.write(encoder.close());
raw.end();
}
}
/**
* The store's resumable methods, only when it has all three AND declares itself resumable — a
* `MediaAttachmentStaging` over a media library without `uploads.resumable` has the methods but
* would answer every one with a 501.
*/
function resumableStaging(staging) {
const candidate = staging;
if (typeof candidate?.beginUpload !== 'function' ||
typeof candidate.completeUpload !== 'function' ||
typeof candidate.discard !== 'function' ||
staging?.describe?.().upload !== 'resumable') {
return undefined;
}
return candidate;
}
//# sourceMappingURL=agent_provider.js.map