UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

230 lines • 9.81 kB
import { ConversationClient, newMessageId, poppyProtocol } from './conversations.js'; import { PoppyError, ResourceAuthError } from './errors.js'; import { connectMcp } from './mcp.js'; /** Cheap, capable, tool-calling model on OpenRouter. Override with --model / POPPY_AGENT_MODEL. */ export const DEFAULT_MODEL = 'anthropic/claude-haiku-5.5'; export const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; function summarize(events) { const out = []; for (const ev of events) { if (ev.type === 'message' && ev.message?.role !== 'user') { out.push({ from: `company:${ev.message?.sender}`, text: ev.message?.text, data: ev.message?.data, }); } else if (ev.type === 'authorization') { out.push({ notice: `The company needs the user to sign in (${ev.error}${ev.scope ? `, scope "${ev.scope}"` : ''}). You cannot sign in yourself; tell the user to run "poppy-agent signin".`, }); } else if (ev.type === 'user_requested') { out.push({ notice: `The company asked to talk with the user directly: ${ev.reason}. The user is not available in this mode.`, }); } } return out; } /** * "LLM mode": a tiny autonomous Personal Agent. It uses an OpenRouter model to work on `task` * through the Company's conversation endpoint and/or MCP tools, within `maxSteps`. It never * sees tokens: tools return only what the Company said. */ export async function ask(company, task, opts) { const doFetch = opts.fetch ?? ((i, init) => globalThis.fetch(i, init)); const maxSteps = opts.maxSteps ?? 8; const tools = []; const conv = poppyProtocol(company) ? new ConversationClient(company) : undefined; let conversationId; let mcp; const mcpTools = new Map(); if (conv) { tools.push({ type: 'function', function: { name: 'talk_to_company', description: "Send a message to the company's own agent and get its reply. Use plain text; put structured details in data.", parameters: { type: 'object', properties: { text: { type: 'string' }, data: { type: 'object', description: 'Optional structured details.' }, }, required: ['text'], }, }, }); } if ((company.document.apis ?? []).some((a) => a?.type === 'mcp')) { try { mcp = await connectMcp(company); const { tools: list } = await mcp.client.listTools(); for (const t of list) { const name = `mcp_${t.name}`.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 64); mcpTools.set(name, { name: t.name, readOnly: t.annotations?.readOnlyHint === true }); tools.push({ type: 'function', function: { name, description: `[company MCP tool] ${t.description ?? t.name}`, parameters: t.inputSchema ?? { type: 'object' }, }, }); } } catch (e) { opts.log(`! MCP unavailable: ${e.message}`); } } if (tools.length === 0) { throw new PoppyError('nothing_to_use', 'Company offers no conversation endpoint or MCP tools'); } tools.push({ type: 'function', function: { name: 'finish', description: 'Finish with the answer for the user.', parameters: { type: 'object', properties: { answer: { type: 'string' } }, required: ['answer'], }, }, }); const messages = [ { role: 'system', content: [ `You are a personal agent acting for one user at ${company.document.organization.name} (${company.document.organization.domain}).`, 'Accomplish the user task with the tools. Be brief with the company agent: no greetings.', 'Never invent personal data about the user. If something needs the user (sign-in, a decision, personal data you do not have), stop and say so in finish.', 'Call finish exactly once when done.', ].join(' '), }, { role: 'user', content: task }, ]; const runTool = async (name, args) => { if (name === 'talk_to_company' && conv) { const message = { id: newMessageId(), sender: 'agent', text: String(args.text ?? ''), data: typeof args.data === 'object' && args.data ? args.data : undefined, context: conversationId ? undefined : { ...opts.context, user_available: false }, }; if (!conversationId) { conversationId = (await conv.start(message)).conversation_id; opts.log(` (conversation ${conversationId})`); } else { await conv.send(conversationId, message); } const { events, state } = await conv.collect(conversationId); for (const ev of events) { if (ev.type === 'message' && ev.message?.role !== 'user') { opts.log(` company[${ev.message?.sender}]: ${ev.message?.text ?? ''}${ev.message?.data ? ` ${JSON.stringify(ev.message.data)}` : ''}`); } if (ev.type === 'user_requested') { // 7.10: only the Personal Agent starts Direct Conversations; the user isn't here. await conv.send(conversationId, { id: newMessageId(), sender: 'agent', text: 'The user is not available to talk right now.', context: { user_available: false }, }); } } return { status: state.status, responder: state.responder, replies: summarize(events) }; } const mt = mcpTools.get(name); if (mt && mcp) { if (!mt.readOnly && !opts.allowWrites) { const ok = opts.confirm ? await opts.confirm(`Allow MCP tool ${mt.name}(${JSON.stringify(args)})?`) : false; if (!ok) return { error: 'refused: this tool may change things and the user did not allow it' }; } const r = await mcp.client.callTool({ name: mt.name, arguments: args }); return r; } return { error: `unknown tool ${name}` }; }; let answer; let steps = 0; try { while (steps < maxSteps && answer === undefined) { steps++; const res = await doFetch(opts.baseUrl ?? OPENROUTER_URL, { method: 'POST', headers: { authorization: `Bearer ${opts.apiKey}`, 'content-type': 'application/json', 'x-title': 'poppy-agent', }, body: JSON.stringify({ model: opts.model ?? DEFAULT_MODEL, messages, tools, tool_choice: 'auto', }), }); if (!res.ok) throw new PoppyError('llm_error', `LLM HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`); const json = (await res.json()); const msg = json.choices?.[0]?.message; if (!msg) throw new PoppyError('llm_error', 'LLM returned no message'); messages.push({ role: 'assistant', content: msg.content ?? null, tool_calls: msg.tool_calls, }); if (msg.content) opts.log(`agent: ${msg.content}`); if (!msg.tool_calls?.length) { answer = msg.content ?? ''; break; } for (const call of msg.tool_calls) { let args = {}; try { args = JSON.parse(call.function.arguments || '{}'); } catch { } opts.log(`> ${call.function.name}(${JSON.stringify(args)})`); if (call.function.name === 'finish') { answer = String(args.answer ?? ''); messages.push({ role: 'tool', tool_call_id: call.id, content: 'ok' }); continue; } let result; try { result = await runTool(call.function.name, args); } catch (e) { result = e instanceof ResourceAuthError ? { error: e.code, ...(e.scope ? { needs_scope: e.scope } : {}) } : { error: e.message }; } const text = JSON.stringify(result); opts.log(`< ${text.length > 600 ? `${text.slice(0, 600)}…` : text}`); messages.push({ role: 'tool', tool_call_id: call.id, content: text }); } } } finally { if (conversationId && conv) await conv.close(conversationId).catch(() => { }); await mcp?.close().catch(() => { }); } if (answer === undefined) opts.log(`! stopped after ${maxSteps} steps without finishing`); return { answer, steps, conversationId }; } //# sourceMappingURL=llm.js.map