/** * ResearchAgent — provider-agnostic tool-use loop over the Lucidly research toolkit. * * Architecture: * * ResearchAgent ─── normalized AgentMessage[] ─→ LLMProvider ─→ Anthropic / Groq / DeepSeek / OpenAI * ←─ normalized LLMResponse ───── * * Two entry points: * • `chat(prompt)` — single async call, returns the final answer. * • `chatStream(prompt, callbacks)` — same loop, but emits incremental text-delta + tool-call * events as the model streams. Use this when wiring into * a UI that wants to show the answer assembling live. * Supports `abortSignal` for user-stop and `getStopped` * flag for stop-between-tool-calls. * * `chat()` is now a thin wrapper around `chatStream()` — both share the same loop body and the * same result shape. Adding a new entry point (e.g. `chatStreamAsync()` returning an AsyncIterable) * is one more wrapper, not a duplicate loop implementation. */ import type { LucidlyClient } from "./client.js"; import { LUCIDLY_RESEARCH_TOOLS, dispatchLucidlyTool, type ResearchToolsModule, } from "./researchTools.js"; import type { AgentBlock, AgentMessage, AgentStopReason, LLMProvider } from "./llm.js"; // ───────────────────────────────────────────────────────────────────────────── // Public types // ───────────────────────────────────────────────────────────────────────────── export interface ResearchAgentConfig { /** LLM provider — AnthropicProvider, GroqProvider, DeepSeekProvider, OpenAIProvider. */ provider: LLMProvider; /** Max output tokens per individual model call. Default 4096. */ maxTokensPerCall?: number; /** Total input+output token cap for one chat() session. Default 60_000. */ sessionTokenCap?: number; /** Max number of model calls per session. Default 10. */ maxIterations?: number; /** Appended to the default system prompt. */ extraSystemPrompt?: string; } export interface ResearchToolCall { name: string; input: Record; resultJson: string; isError: boolean; } export interface ResearchAgentResult { text: string; toolCalls: ResearchToolCall[]; tokensUsed: { input: number; output: number }; cappedOut: boolean; /** True iff the user (via abortSignal or getStopped) stopped the run. */ stopped: boolean; iterations: number; stopReason: AgentStopReason; providerInfo: { name: string; model: string }; } /** Callbacks for `chatStream`. All optional. */ export interface ChatStreamCallbacks { /** Called at the start of each iteration (each model API call). 1-indexed. */ onIterationStart?: (iteration: number) => void; /** Called for each streamed text fragment from the model. Use for live-typing UI. */ onTextDelta?: (chunk: string) => void; /** Called once per tool call after it's been dispatched + result captured. */ onToolCall?: (call: ResearchToolCall) => void; /** Called when token usage updates after each iteration. */ onTokens?: (tokens: { input: number; output: number }) => void; /** Called when the provider hit 429 and is backing off. Forwarded from provider. */ onRateLimit?: (info: { waitSec: number; attempt: number; maxRetries: number }) => void; /** AbortSignal — abort the in-flight fetch + bail the loop. */ abortSignal?: AbortSignal; /** Optional polled stop predicate. Checked between tool calls + at iteration boundaries. */ getStopped?: () => boolean; } // ───────────────────────────────────────────────────────────────────────────── // Default system prompt // ───────────────────────────────────────────────────────────────────────────── const DEFAULT_SYSTEM_PROMPT = `You are a research assistant for Lucidly Pro — a permissionless tokenized vault platform built on the Boring Vault stack, deployed across Ethereum, Base, Arbitrum, and Optimism. How Lucidly Pro works: - Anyone can deploy a vault via LucidlyFactoryV2 at 0xF13ca1C318fD9bDd851F5C1334A1dd35302684fd (same address on all four chains via Arachnid CREATE2). v2.4.2 (0xFC93bC7e…) and v2.3 (0x6612E3DE…) were prior versions and are fully deprecated as of 2026-06-22. v2.4.3 adds DC-17 (public-deposit RA capability fix). - Each vault has a BoringVault (the share token + fund holder), a Manager that executes Merkle-gated strategist actions, an Accountant that pushes NAV (exchangeRate), a Teller that handles deposits, a per-vault BoringOnChainQueue + BoringSolver that handles queued user withdrawals, and (where Morpho is deployed) a per-vault MorphoFlashLoanAdapter that lets strategies use Morpho Blue's free flashloans for atomic position changes. - Strategists can only execute actions that have been pre-whitelisted as "policy templates" in the on-chain LucidlyRegistry at 0xA6214AFe2a6a21f990e92257bA90A5Bf01F3F5be. A policy = (decoder, target, selector, argsHash) — i.e. "this exact call shape is allowed". - NAV is computed off-chain by a NavPipeline that scans all enabled policies for a vault, sums TVL via per-protocol TvlAdapters and Oracles, then pushes to the Accountant. Your job: - Answer research questions about specific vaults, policies, holders, positions, and external protocol state (Morpho Blue markets, Aave V3 reserves, ERC20 metadata). - ALWAYS prefer calling a tool over guessing. If the user asks about a specific vault, policy, position, or market, call the matching tool. Don't hallucinate addresses, balances, APYs, or health factors. - Token amounts come back as raw integer strings (because they are uint256). Use chain.tokenInfo to look up decimals and format human-readable values in your final answer (e.g. "1.50M USDC", not "1500000000000"). - Aave V3 health factor is uint256 scaled by 1e18; a value near uint256-max means the account has zero debt (no liquidation risk). - Aave V3 reports collateral/debt in the pool's base currency (USD, 8 decimals on mainnet + L2s) — mention units when surfacing these. - If the user asks about something none of your tools cover (e.g. "what was the Curve yield last month"), say "I don't have a tool for that yet" rather than making something up. Lucidly Pro users would rather know the gap than be misled. - Keep answers concise. Markdown headings + bullets for multi-part answers; plain prose for short ones. Always cite the specific addresses / chain / market id you fetched so the user can verify on a block explorer. Safety: - This is research-only. You CANNOT execute transactions, sign anything, or change on-chain state. If a user asks you to "open a leverage loop" or "rebalance the vault", explain that you can analyze and surface required policies, but the user must execute via the SDK / UI themselves. - Never fabricate transaction hashes, block numbers, or "I just executed X" claims. You have no broadcast capability.`; // ───────────────────────────────────────────────────────────────────────────── // ResearchAgent // ───────────────────────────────────────────────────────────────────────────── export class ResearchAgent { private readonly tools: ResearchToolsModule; private readonly provider: LLMProvider; private readonly maxTokensPerCall: number; private readonly sessionTokenCap: number; private readonly maxIterations: number; private readonly systemPrompt: string; constructor(client: LucidlyClient, cfg: ResearchAgentConfig) { if (!cfg.provider) { throw new Error( "ResearchAgent: provider is required (use AnthropicProvider, GroqProvider, DeepSeekProvider, or OpenAIProvider)", ); } this.tools = client.researchTools; this.provider = cfg.provider; this.maxTokensPerCall = cfg.maxTokensPerCall ?? 4096; this.sessionTokenCap = cfg.sessionTokenCap ?? 60_000; this.maxIterations = cfg.maxIterations ?? 10; this.systemPrompt = DEFAULT_SYSTEM_PROMPT + (cfg.extraSystemPrompt ? "\n\n---\n\n" + cfg.extraSystemPrompt : ""); } get providerInfo(): { name: string; model: string } { return { name: this.provider.name, model: this.provider.model }; } /** * Run a single research question end-to-end with no UI feedback. Returns when the provider * reports `end_turn` with no further tool_call blocks, or when we hit an iteration/token cap. * * Thin wrapper around `chatStream()` with no callbacks. Use `chatStream` for live UI updates. */ async chat(userPrompt: string): Promise { return this.chatStream(userPrompt, {}); } /** * Run a single research question with streaming callbacks. Emits text deltas as the model * streams, tool-call events as tools execute, and respects abortSignal / getStopped. */ async chatStream(userPrompt: string, callbacks: ChatStreamCallbacks): Promise { if (!userPrompt || !userPrompt.trim()) { throw new Error("ResearchAgent.chatStream: empty prompt"); } const { onIterationStart, onTextDelta, onToolCall, onTokens, onRateLimit, abortSignal, getStopped } = callbacks; const messages: AgentMessage[] = [ { role: "user", content: [{ type: "text", text: userPrompt }] }, ]; const toolCalls: ResearchToolCall[] = []; let totalInput = 0; let totalOutput = 0; let iterations = 0; let stopReason: AgentStopReason = null; const stopped = () => (getStopped ? getStopped() : false); const buildResult = (extra: Partial): ResearchAgentResult => ({ text: "", toolCalls, tokensUsed: { input: totalInput, output: totalOutput }, cappedOut: false, stopped: false, iterations, stopReason, providerInfo: { name: this.provider.name, model: this.provider.model }, ...extra, }); for (let i = 0; i < this.maxIterations; i++) { if (stopped()) return buildResult({ text: "(Stopped by user.)", stopped: true }); if (totalInput + totalOutput >= this.sessionTokenCap) { return buildResult({ text: "(Session token cap hit before final answer.)", cappedOut: true, }); } iterations = i + 1; onIterationStart?.(iterations); let response; try { response = await this.provider.complete({ system: this.systemPrompt, tools: LUCIDLY_RESEARCH_TOOLS, messages, maxTokens: this.maxTokensPerCall, onTextDelta, onRateLimit, abortSignal, }); } catch (e) { if ((e as { name?: string })?.name === "AbortError" || stopped()) { return buildResult({ text: "(Stopped by user.)", stopped: true }); } throw e; } stopReason = response.stopReason; totalInput += response.usage.input; totalOutput += response.usage.output; messages.push({ role: "assistant", content: response.content }); onTokens?.({ input: totalInput, output: totalOutput }); const toolCallBlocks = response.content.filter( (b): b is Extract => b.type === "tool_call", ); if (toolCallBlocks.length === 0) { return buildResult({ text: collectText(response.content) }); } // Execute every requested tool. Check `stopped` between calls so a click between tool 2 and // tool 3 stops cleanly. Batch results into one user turn — both adapters round-trip cleanly. const toolResultBlocks: Extract[] = []; for (const tc of toolCallBlocks) { if (stopped()) return buildResult({ text: "(Stopped by user.)", stopped: true }); const call = await this.runOneTool(tc); toolCalls.push(call); onToolCall?.(call); toolResultBlocks.push({ type: "tool_result", toolCallId: tc.id, content: call.resultJson, isError: call.isError || undefined, }); } messages.push({ role: "user", content: toolResultBlocks }); } // Iteration cap hit. const reason = totalInput + totalOutput >= this.sessionTokenCap ? "the session token cap" : `the iteration cap of ${this.maxIterations}`; return buildResult({ text: "(Research agent stopped before producing a final answer: hit " + reason + ". Tools called: " + (toolCalls.length === 0 ? "none" : toolCalls.map((t) => t.name).join(", ")) + ".)", cappedOut: true, }); } private async runOneTool( block: Extract, ): Promise { try { const result = await dispatchLucidlyTool(this.tools, block.name, block.input); const resultJson = JSON.stringify(result, bigintReplacer, 2); return { name: block.name, input: block.input, resultJson, isError: false }; } catch (e) { const message = e instanceof Error ? e.message : String(e); return { name: block.name, input: block.input, resultJson: JSON.stringify({ error: message }), isError: true, }; } } } // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── function collectText(blocks: AgentBlock[]): string { return blocks .filter((b): b is Extract => b.type === "text") .map((b) => b.text) .join("\n") .trim(); } function bigintReplacer(_key: string, value: unknown): unknown { return typeof value === "bigint" ? value.toString() : value; }