@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
186 lines • 8.32 kB
JavaScript
import { onActionProposalAdmitted } from '../src/action-proposal-listeners.js';
import { AgentService } from '../src/agent-service.js';
import { createPoppyConnectionsHandler, } from '../src/poppy-connections/http.js';
import { continueAfterConnect, resumeAfterConnect } from '../src/poppy-connections/resume.js';
import { setPoppyConnectionsHandler } from '../src/poppy-connections/runtime.js';
import { PoppyConnections } from '../src/poppy-connections/service.js';
import { InMemoryPoppyConnectionStore, LucidPoppyConnectionStore, } from '../src/poppy-connections/store.js';
import { registerPoppyConnectionTools } from '../src/poppy-connections/tools.js';
import { ToolRegistry } from '../src/tool-registry.js';
function configOf(app) {
const config = app.config.get('poppy_connections', undefined);
if (!config || typeof config !== 'object')
return undefined;
if ('default' in config && Object.keys(config).length === 1)
return undefined;
return config;
}
/**
* This app's agent working with OTHER Poppy Companies for its users (`config/poppy_connections.ts`):
* the agent identity routes, the connect pages and callback, the `poppy_*` tools, and — when the
* config has `api` — `GET/DELETE {path}/api/connections` for the app's own settings UI.
*
* @experimental Tracks Personal Agent Protocol Draft 0.1 — will change as the spec evolves.
*/
export default class PoppyConnectionsProvider {
app;
#service = null;
#unsubscribe = null;
constructor(app) {
this.app = app;
}
register() {
const config = configOf(this.app);
if (!config)
return;
this.app.container.singleton(PoppyConnections, () => this.#serviceFor(config));
}
async boot() {
const config = configOf(this.app);
if (!config)
return;
const connections = await this.app.container.make(PoppyConnections);
// The agent provider's registry when there is one; an app with its own loop registers the tools
// in its own registry (`tools.register: false` + `registerPoppyConnectionTools`).
if (config.tools?.register !== false && this.app.container.hasBinding(ToolRegistry)) {
const registry = await this.app.container.make(ToolRegistry);
registerPoppyConnectionTools(registry, connections, config.tools ?? {});
}
const logger = await this.app.container.make('logger');
// Independent approvals: once the decided `connect` ran and its outcome is in the thread, go on
// with the person's request (a new turn), on the channel it came from.
if (config.tools?.connect !== 'link') {
this.#unsubscribe = onActionProposalAdmitted(async (outcome) => {
if (!this.app.container.hasBinding(AgentService))
return;
const service = await this.app.container.make(AgentService);
if (service.actionApprovalMode() !== 'independent')
return;
const { channels } = await import('../src/channels/handler.js');
await continueAfterConnect(service, connections, outcome, {
...(config.tools?.prefix !== undefined ? { prefix: config.tools.prefix } : {}),
follow: channels.follow,
}).catch((error) => logger.error({ err: error }, 'poppy-connections: continuing after connect failed'));
});
}
const handle = createPoppyConnectionsHandler(connections, {
...(config.render !== undefined ? { render: config.render } : {}),
onError: (error) => logger.error({ err: error }, 'poppy-connections: request failed'),
});
const server = await this.app.container.make('server');
server.use([() => import('../src/poppy-connections/server_middleware.js')]);
setPoppyConnectionsHandler((ctx) => handle(exchange(ctx)));
const api = config.api;
if (api) {
const router = await this.app.container.make('router');
const base = `/${connections.path}/api/connections`;
const who = async (ctx) => {
const actor = await api.actor(ctx);
if (!actor)
ctx.response.unauthorized({ error: 'unauthorized' });
return actor;
};
router
.get(base, async (ctx) => {
const actor = await who(ctx);
if (!actor)
return;
return { connections: await connections.list(connections.ownerOf(actor)) };
})
.as('poppy_connections.list');
router
.delete(`${base}/:domain`, async (ctx) => {
const actor = await who(ctx);
if (!actor)
return;
const removed = await connections.disconnect(connections.ownerOf(actor), String(ctx.params.domain));
return { disconnected: removed };
})
.as('poppy_connections.disconnect');
}
}
async shutdown() {
this.#unsubscribe?.();
this.#unsubscribe = null;
setPoppyConnectionsHandler(null);
}
#serviceFor(config) {
if (this.#service === null) {
this.#service = this.#create(config);
this.#service.catch(() => {
this.#service = null;
});
}
return this.#service;
}
async #create(config) {
const logger = await this.app.container.make('logger');
const appKey = this.app.config.get('app.appKey');
const secret = config.secret ??
(typeof appKey?.release === 'function'
? appKey.release()
: String(appKey ?? ''));
return new PoppyConnections({
config,
store: await this.#store(config),
secret,
production: this.app.inProduction,
onConnected: (pending) => this.#resume(pending),
log: (line) => logger.warn(line),
});
}
async #store(config) {
const store = config.store ?? 'lucid';
if (typeof store === 'object')
return store;
if (store === 'memory')
return new InMemoryPoppyConnectionStore();
const db = (await this.app.container.make('lucid.db'));
return new LucidPoppyConnectionStore(db.connection(config.connection), {
...(config.autoCreateTables !== undefined
? { autoCreateTables: config.autoCreateTables }
: {}),
});
}
async #resume(pending) {
// No AgentService (an app running its own loop): nothing parked to resume.
if (!this.app.container.hasBinding(AgentService))
return;
const { channels } = await import('../src/channels/handler.js');
// A sign-in that finished after its card expired still continues the conversation.
await resumeAfterConnect(await this.app.container.make(AgentService), pending, {
connections: await this.app.container.make(PoppyConnections),
follow: channels.follow,
});
}
}
function exchange(ctx) {
const original = ctx.request.url(true) ?? ctx.request.url() ?? '/';
const target = new URL(original, 'http://local/');
return {
method: ctx.request.method(),
path: target.pathname,
query: target.searchParams,
async readBody(limit) {
const raw = ctx.request.raw?.();
if (typeof raw === 'string')
return raw;
const chunks = [];
let size = 0;
for await (const chunk of ctx.request.request) {
size += chunk.length;
if (size > limit)
throw new Error('body too large');
chunks.push(chunk);
}
return Buffer.concat(chunks).toString('utf8');
},
send(status, headers, body) {
ctx.response.status(status);
for (const [name, value] of Object.entries(headers))
ctx.response.header(name, value);
ctx.response.send(body);
},
};
}
//# sourceMappingURL=poppy_connections_provider.js.map