import { type Address, type Hex, encodeAbiParameters, parseAbiParameters, keccak256, isAddressEqual } from "viem"; import type { LucidlyClient } from "./client.js"; import { POLICY_MANAGER_ABI } from "./policyManager.js"; /** * ResearchToolsModule — Lucidly-native read-only knowledge tools the research agent calls. * * This file is the W2-1 starting point per docs/WORKFLOWS-DESIGN.md. Each public method on the * module is a "tool" the LLM can invoke by name; the JSON descriptor for each tool is exported as * `LUCIDLY_RESEARCH_TOOLS` so the agent loop can advertise the toolset to the model. * * Design constraints: * • Pure read-only. No tool here can broadcast a tx — that's W4's job and is gated by user consent. * • Stateless. Tools take a vault address (or other primary key) as input; no hidden context. * • Composable. Tools return plain JSON-friendly objects so the agent can pass them between calls. * • Cheap. Each tool is one indexer query or one on-chain read batch — token-cost discipline. * • Defensive. When the indexer is unreachable or a vault is unknown, tools return empty/null * rather than throwing, so the agent can degrade gracefully. */ // ── Result types ───────────────────────────────────────────────────────────── /** Holder of a vault's share token, with current balance derived from the share-transfer ledger. */ export interface HolderRow { address: Address; /** Share balance in vault decimals (bigint serialized as string for JSON compatibility). */ balance: string; } /** A deposit event row, as seen by the indexer's Teller-deposit handler. */ export interface DepositorRow { receiver: Address; depositAsset: Address; /** Base-asset amount deposited (atomic units, string-encoded). */ depositAmount: string; /** Share amount minted (atomic units, string-encoded). */ shareAmount: string; at: string; // unix timestamp (seconds, string-encoded) txHash: Hex; } /** A single NAV/share rate update from the Accountant. */ export interface NavPoint { at: string; // unix timestamp (seconds) oldRate: string; // atomic base units newRate: string; txHash: Hex; } /** A policy that the vault's PolicyManager has enabled (currently in its Merkle root). */ export interface EnabledPolicy { leaf: Hex; } /** Status of one protocol the vault has touched (or could touch). */ export interface ProtocolPolicyStatus { protocolId: Hex; /** Components currently cloned for this vault — decoder/tvlAdapter/oracle. */ decoder: Address; tvlAdapter: Address; oracle: Address; exists: boolean; } /** A non-whitelisted policy request waiting in the owner-review queue. */ export interface PolicyRequestRow { chainId: number; protocolId: Hex; target: Address; selector: Hex; note: string; requestedBy: Address; requestedAt: string; txHash: Hex; } /** Morpho Blue MarketParams — the 5-tuple that hashes to a market's id. */ export interface MorphoMarketParams { loanToken: Address; collateralToken: Address; oracle: Address; irm: Address; /** Liquidation LTV, on the 1e18 scale (0.86e18 → 86%). */ lltv: string; } /** Morpho Blue market state. */ export interface MorphoMarketState { marketId: Hex; params: MorphoMarketParams; totalSupplyAssets: string; totalSupplyShares: string; totalBorrowAssets: string; totalBorrowShares: string; lastUpdate: string; fee: string; /** Derived: total supply - total borrow, in loan-token atomic units. */ availableLiquidity: string; /** Derived: utilization (borrowAssets / supplyAssets), on the 1e18 scale. 0 if supply is zero. */ utilization: string; } /** Morpho Blue per-account position. */ export interface MorphoPosition { marketId: Hex; account: Address; supplyShares: string; borrowShares: string; /** Collateral in collateral-token atomic units. */ collateral: string; } /** Aave V3 account-level summary (Pool.getUserAccountData). */ export interface AavePosition { account: Address; /** All four base-denominated values are in Aave's "base currency" — USD on mainnet/L2s, 8 decimals. */ totalCollateralBase: string; totalDebtBase: string; availableBorrowsBase: string; currentLiquidationThresholdBps: number; ltvBps: number; /** Health factor on the 1e18 scale. >1.0 = healthy; <1.0 = liquidatable. uint(-1) when no debt. */ healthFactor: string; } /** Aave V3 reserve state (the useful subset of Pool.getReserveData). */ export interface AaveReserve { asset: Address; aToken: Address; variableDebtToken: Address; /** Supply APY in ray (1e27 scale). Divide by 1e27 for fractional, ×100 for %. */ liquidityRate: string; /** Variable-rate borrow APY in ray. */ variableBorrowRate: string; /** Per-second indexes (also in ray) — accrue between updates. */ liquidityIndex: string; variableBorrowIndex: string; lastUpdateTimestamp: number; } /** ERC20 token metadata read live from chain. (Distinct from `TokenInfo` in chains.ts, which is * static metadata bundled with the SDK for tokens the SDK already knows about.) */ export interface Erc20TokenInfo { asset: Address; name: string; symbol: string; decimals: number; } // ── Anthropic tool-use descriptors ─────────────────────────────────────────── // Shape matches https://docs.anthropic.com/en/docs/build-with-claude/tool-use — `name`, // `description`, `input_schema` (JSON Schema). The research agent feeds this array to the model // at each turn so it knows which tools are callable. export interface ToolDescriptor { name: string; description: string; input_schema: Record; } const ADDRESS_SCHEMA = { type: "string", pattern: "^0x[a-fA-F0-9]{40}$", description: "EVM address (0x-prefixed)." }; export const LUCIDLY_RESEARCH_TOOLS: ToolDescriptor[] = [ { name: "lucidly.policies", description: "Return the per-protocol policy status for a Lucidly Pro vault. For each protocol the vault has touched, " + "lists the cloned components (decoder, tvlAdapter, oracle) and the currently enabled Merkle leaves. " + "Use this to answer questions like 'what can this vault do', 'which strategies are enabled', " + "or as the first step when proposing a new strategy (to see what's already wired vs missing).", input_schema: { type: "object", properties: { vault: ADDRESS_SCHEMA }, required: ["vault"], }, }, { name: "lucidly.holders", description: "List the current share-token holders of a Lucidly Pro vault, ordered by share balance descending. " + "Balances are derived from the indexer's share-transfer ledger by summing mints/transfers/burns per address. " + "Use this for 'who owns this vault', 'how concentrated is ownership', or due-diligence questions.", input_schema: { type: "object", properties: { vault: ADDRESS_SCHEMA, limit: { type: "integer", minimum: 1, maximum: 200, default: 50 }, }, required: ["vault"], }, }, { name: "lucidly.depositors", description: "Return the recent deposit history for a Lucidly Pro vault, newest first. Each row is one Teller deposit. " + "Use for 'who deposited recently', 'what's the average deposit size', or to inform flow-based decisions.", input_schema: { type: "object", properties: { vault: ADDRESS_SCHEMA, limit: { type: "integer", minimum: 1, maximum: 200, default: 50 }, }, required: ["vault"], }, }, { name: "lucidly.navHistory", description: "Return the vault's NAV (exchange rate) history, oldest first. Each row is one ExchangeRateUpdated event. " + "Use for 'has this vault performed well', 'when was the last rate push', 'what's the drawdown'.", input_schema: { type: "object", properties: { vault: ADDRESS_SCHEMA, limit: { type: "integer", minimum: 1, maximum: 1000, default: 200 }, }, required: ["vault"], }, }, { name: "lucidly.policyRequests", description: "Return open policy requests across all vaults (or filtered to a specific vault's PolicyManager). " + "Requests appear when a strategist asks for a policy that isn't yet in the Registry; the owner reviews + " + "whitelists it. Use to surface 'pending whitelist asks' or to inform 'should I request this policy too'.", input_schema: { type: "object", properties: { policyManager: { ...ADDRESS_SCHEMA, description: "Optional. Filter to one vault's PolicyManager." }, limit: { type: "integer", minimum: 1, maximum: 200, default: 50 }, }, }, }, { name: "morpho.market", description: "Read a Morpho Blue market's current state by passing its 5-field MarketParams tuple " + "(loanToken, collateralToken, oracle, irm, lltv). Returns total supply/borrow, utilization, " + "available liquidity, fee, and last update. Use when proposing a Morpho strategy ('how deep " + "is wstETH/wETH liquidity'), or to verify a market is active before a leverage loop. The " + "MarketParams identify the market — Morpho Blue has many markets per token pair, " + "distinguished by their oracle / IRM / LLTV.", input_schema: { type: "object", properties: { loanToken: ADDRESS_SCHEMA, collateralToken: ADDRESS_SCHEMA, oracle: ADDRESS_SCHEMA, irm: ADDRESS_SCHEMA, lltv: { type: "string", description: "Liquidation LTV on the 1e18 scale, as a decimal string (e.g. '860000000000000000' for 86%)." }, }, required: ["loanToken", "collateralToken", "oracle", "irm", "lltv"], }, }, { name: "morpho.position", description: "Read a specific account's Morpho Blue position in one market — supply shares, borrow shares, " + "and collateral balance. Pass the same MarketParams 5-tuple as morpho.market plus the account " + "address. Use for 'what's the vault's current Morpho exposure', 'how much room before " + "liquidation', or before a withdraw/repay.", input_schema: { type: "object", properties: { loanToken: ADDRESS_SCHEMA, collateralToken: ADDRESS_SCHEMA, oracle: ADDRESS_SCHEMA, irm: ADDRESS_SCHEMA, lltv: { type: "string", description: "Liquidation LTV on the 1e18 scale." }, account: ADDRESS_SCHEMA, }, required: ["loanToken", "collateralToken", "oracle", "irm", "lltv", "account"], }, }, { name: "aave.position", description: "Read an account's Aave V3 position summary: total collateral, total debt, available borrows " + "(all in Aave's base currency — USD with 8 decimals on mainnet/L2s), liquidation threshold, " + "LTV, and the all-important health factor (1e18 scale, >1.0 = healthy). Use this for 'is the " + "vault safe', 'how much can I borrow', or to compute a buffer before a leverage adjustment.", input_schema: { type: "object", properties: { account: ADDRESS_SCHEMA }, required: ["account"], }, }, { name: "aave.market", description: "Read an Aave V3 reserve's current state for one asset: supply APY (liquidityRate), variable " + "borrow APY, accrued indexes, and the aToken / variableDebtToken addresses. Rates are in ray " + "(1e27 scale) — divide by 1e25 to get a percentage. Use when comparing yield across pools or " + "before composing a supply/borrow strategy.", input_schema: { type: "object", properties: { asset: ADDRESS_SCHEMA }, required: ["asset"], }, }, { name: "chain.tokenInfo", description: "Return ERC20 metadata for any token on the active chain: name, symbol, decimals. Use to " + "verify a token address, or to format values in answers ('the vault holds 100 USDC' rather " + "than '100000000 atomic units').", input_schema: { type: "object", properties: { asset: ADDRESS_SCHEMA }, required: ["asset"], }, }, ]; // ── Protocol ABIs + addresses ──────────────────────────────────────────────── /** Morpho Blue — chain-invariant via CREATE2. Deployed on Ethereum + Base. Op/Arb: not deployed. */ export const MORPHO_BLUE_PER_CHAIN: Record = { 1: "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb", 8453: "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb", }; const MORPHO_MARKET_PARAMS_TUPLE = { name: "marketParams", type: "tuple", components: [ { name: "loanToken", type: "address" }, { name: "collateralToken", type: "address" }, { name: "oracle", type: "address" }, { name: "irm", type: "address" }, { name: "lltv", type: "uint256" }, ], } as const; const MORPHO_BLUE_ABI = [ { type: "function", name: "market", stateMutability: "view", inputs: [{ name: "id", type: "bytes32" }], outputs: [ { name: "totalSupplyAssets", type: "uint128" }, { name: "totalSupplyShares", type: "uint128" }, { name: "totalBorrowAssets", type: "uint128" }, { name: "totalBorrowShares", type: "uint128" }, { name: "lastUpdate", type: "uint128" }, { name: "fee", type: "uint128" }, ], }, { type: "function", name: "position", stateMutability: "view", inputs: [{ name: "id", type: "bytes32" }, { name: "user", type: "address" }], outputs: [ { name: "supplyShares", type: "uint256" }, { name: "borrowShares", type: "uint128" }, { name: "collateral", type: "uint128" }, ], }, ] as const; /** Compute a Morpho Blue market id from MarketParams: keccak256(abi.encode(loanToken, collateralToken, oracle, irm, lltv)). */ export function morphoMarketId(p: MorphoMarketParams): Hex { const enc = encodeAbiParameters( parseAbiParameters("address, address, address, address, uint256"), [p.loanToken, p.collateralToken, p.oracle, p.irm, BigInt(p.lltv)], ); return keccak256(enc); } /** Aave V3 Pool: minimal ABI surface for our read tools. */ const AAVE_V3_POOL_ABI = [ { type: "function", name: "getUserAccountData", stateMutability: "view", inputs: [{ name: "user", type: "address" }], outputs: [ { name: "totalCollateralBase", type: "uint256" }, { name: "totalDebtBase", type: "uint256" }, { name: "availableBorrowsBase", type: "uint256" }, { name: "currentLiquidationThreshold", type: "uint256" }, { name: "ltv", type: "uint256" }, { name: "healthFactor", type: "uint256" }, ], }, { type: "function", name: "getReserveData", stateMutability: "view", inputs: [{ name: "asset", type: "address" }], outputs: [{ // ReserveData struct (Aave V3) — positional decode below name: "data", type: "tuple", components: [ { name: "configuration", type: "tuple", components: [{ name: "data", type: "uint256" }] }, { name: "liquidityIndex", type: "uint128" }, { name: "currentLiquidityRate", type: "uint128" }, { name: "variableBorrowIndex", type: "uint128" }, { name: "currentVariableBorrowRate", type: "uint128" }, { name: "currentStableBorrowRate", type: "uint128" }, { name: "lastUpdateTimestamp", type: "uint40" }, { name: "id", type: "uint16" }, { name: "aTokenAddress", type: "address" }, { name: "stableDebtTokenAddress", type: "address" }, { name: "variableDebtTokenAddress", type: "address" }, { name: "interestRateStrategyAddress", type: "address" }, { name: "accruedToTreasury", type: "uint128" }, { name: "unbacked", type: "uint128" }, { name: "isolationModeTotalDebt", type: "uint128" }, ], }], }, ] as const; const ERC20_METADATA_ABI = [ { type: "function", name: "name", stateMutability: "view", inputs: [], outputs: [{ type: "string" }] }, { type: "function", name: "symbol", stateMutability: "view", inputs: [], outputs: [{ type: "string" }] }, { type: "function", name: "decimals", stateMutability: "view", inputs: [], outputs: [{ type: "uint8" }] }, ] as const; // ── Module ─────────────────────────────────────────────────────────────────── export class ResearchToolsModule { constructor(private readonly client: LucidlyClient) {} /** * Per-protocol policy status for one vault. * * Strategy: * 1. Indexer tells us which vault row this address belongs to (we trust the indexer's * denormalized cache to know its policyManager + accountant + teller addresses). * 2. PolicyManager.usedProtocolsList() gives the set of protocols this vault has clones for. * 3. For each protocol, read clonesOf(protocolId) to get the per-vault component addresses. * 4. PolicyManager.enabledLeavesList() gives the currently enabled Merkle leaves (un-attributed * to protocol — leaves don't carry their protocolId, so we return them as a flat list). */ async policies(opts: { vault: Address }): Promise<{ vault: Address; policyManager: Address | null; perProtocol: ProtocolPolicyStatus[]; enabledLeaves: EnabledPolicy[]; }> { if (!this.client.indexer) return { vault: opts.vault, policyManager: null, perProtocol: [], enabledLeaves: [] }; const vaults = (await this.client.indexer.vaults()) ?? []; const row = vaults.find((v) => isAddressEqual(v.vault, opts.vault)); if (!row) return { vault: opts.vault, policyManager: null, perProtocol: [], enabledLeaves: [] }; const policyManager = row.policyManager; const [used, leaves] = await Promise.all([ this.client.read({ address: policyManager, abi: POLICY_MANAGER_ABI, functionName: "usedProtocolsList", }), this.client.read({ address: policyManager, abi: POLICY_MANAGER_ABI, functionName: "enabledLeavesList", }), ]); const perProtocol: ProtocolPolicyStatus[] = []; for (const protocolId of used) { const tup = (await this.client.read({ address: policyManager, abi: POLICY_MANAGER_ABI, functionName: "clonesOf", args: [protocolId], })) as readonly [Address, Address, Address, boolean]; perProtocol.push({ protocolId, decoder: tup[0], tvlAdapter: tup[1], oracle: tup[2], exists: tup[3], }); } return { vault: opts.vault, policyManager, perProtocol, enabledLeaves: leaves.map((leaf) => ({ leaf })), }; } /** * Current share-token holders for a vault, balance descending. * * Implementation: pull the full share-transfer ledger (capped by indexer pagination) and * aggregate. For very large vaults this gets slow; if it becomes a hot path we'll add a * Postgres aggregation route to the indexer. For now it's bounded by the indexer's 200-row * `shareTransfers` default — we explicitly request 200, which is plenty for early-stage vaults. */ async holders(opts: { vault: Address; limit?: number }): Promise { if (!this.client.indexer) return []; const transfers = (await this.client.indexer.shareTransfers(opts.vault, 200)) ?? []; if (transfers.length === 0) return []; const ZERO = "0x0000000000000000000000000000000000000000".toLowerCase(); const balances: Record = {}; for (const t of transfers) { const amount = BigInt(t.amount); const from = t.from.toLowerCase(); const to = t.to.toLowerCase(); if (from !== ZERO) balances[from] = (balances[from] ?? 0n) - amount; if (to !== ZERO) balances[to] = (balances[to] ?? 0n) + amount; } const limit = Math.min(opts.limit ?? 50, 200); return Object.entries(balances) .filter(([, bal]) => bal > 0n) .sort((a, b) => (b[1] > a[1] ? 1 : b[1] < a[1] ? -1 : 0)) .slice(0, limit) .map(([addr, bal]) => ({ address: addr as Address, balance: bal.toString() })); } /** Recent Teller deposits for a vault, newest first. Direct indexer pass-through. */ async depositors(opts: { vault: Address; limit?: number }): Promise { if (!this.client.indexer) return []; const rows = (await this.client.indexer.deposits(opts.vault, opts.limit ?? 50)) ?? []; return rows.map((d) => ({ receiver: d.receiver, depositAsset: d.depositAsset, depositAmount: d.depositAmount, shareAmount: d.shareAmount, at: d.at, txHash: d.txHash, })); } /** NAV history for a vault, oldest first. Direct indexer pass-through. */ async navHistory(opts: { vault: Address; limit?: number }): Promise { if (!this.client.indexer) return []; const rows = (await this.client.indexer.rateHistory(opts.vault, opts.limit ?? 200)) ?? []; return rows.map((r) => ({ at: r.at, oldRate: r.oldRate, newRate: r.newRate, txHash: r.txHash, })); } /** * Open policy requests across all vaults. Optionally filtered to one vault's PolicyManager — * the indexer keys these by policyManager address, so the filter is exact-match. We pull from * the indexer's global feed and filter in TS because the indexer's GraphQL `where` clause on * policyRequests doesn't yet support `policyManager` (could be added later). */ async policyRequests(opts: { policyManager?: Address; limit?: number } = {}): Promise { if (!this.client.indexer) return []; const rows = (await this.client.indexer.policyRequests(opts.limit ?? 50)) ?? []; const filtered = opts.policyManager ? rows.filter((r) => { // The indexed `policyRequest` row has the policyManager in its primary-key prefix but // isn't surfaced in the IndexedPolicyRequest type yet. For MVP we return all rows when // no filter is set; when one is set, we'd need to query the indexer differently. Flag // as a known limitation — TODO: add `policyManager` to IndexerClient.policyRequests query. void r; return true; }) : rows; return filtered.map((r) => ({ chainId: r.chainId, protocolId: r.protocolId, target: r.target, selector: r.selector, note: r.note, requestedBy: r.requestedBy, requestedAt: r.requestedAt, txHash: r.txHash, })); } // ── External protocol reads ──────────────────────────────────────────────── /** * Read a Morpho Blue market's current state. The MarketParams 5-tuple identifies the market — * Morpho Blue can have many markets per (loan, collateral) pair, differentiated by * oracle/irm/lltv. The market id is keccak256(abi.encode(params)). * * Throws if Morpho Blue isn't deployed on the client's active chain (currently Op/Arb). */ async morphoMarket(params: MorphoMarketParams): Promise { const morpho = MORPHO_BLUE_PER_CHAIN[this.client.chainConfig.id]; if (!morpho) throw new Error(`morpho.market: Morpho Blue not deployed on chain ${this.client.chainConfig.id}`); const id = morphoMarketId(params); const tup = (await this.client.read({ address: morpho, abi: MORPHO_BLUE_ABI, functionName: "market", args: [id], })) as readonly [bigint, bigint, bigint, bigint, bigint, bigint]; const totalSupplyAssets = tup[0]; const totalBorrowAssets = tup[2]; const availableLiquidity = totalSupplyAssets > totalBorrowAssets ? totalSupplyAssets - totalBorrowAssets : 0n; const utilization = totalSupplyAssets === 0n ? 0n : (totalBorrowAssets * 10n ** 18n) / totalSupplyAssets; return { marketId: id, params, totalSupplyAssets: tup[0].toString(), totalSupplyShares: tup[1].toString(), totalBorrowAssets: tup[2].toString(), totalBorrowShares: tup[3].toString(), lastUpdate: tup[4].toString(), fee: tup[5].toString(), availableLiquidity: availableLiquidity.toString(), utilization: utilization.toString(), }; } /** Read a specific account's Morpho Blue position in one market. */ async morphoPosition(params: MorphoMarketParams, account: Address): Promise { const morpho = MORPHO_BLUE_PER_CHAIN[this.client.chainConfig.id]; if (!morpho) throw new Error(`morpho.position: Morpho Blue not deployed on chain ${this.client.chainConfig.id}`); const id = morphoMarketId(params); const tup = (await this.client.read({ address: morpho, abi: MORPHO_BLUE_ABI, functionName: "position", args: [id, account], })) as readonly [bigint, bigint, bigint]; return { marketId: id, account, supplyShares: tup[0].toString(), borrowShares: tup[1].toString(), collateral: tup[2].toString(), }; } /** Read an account's Aave V3 position summary. Uses the active chain's Aave Pool address from chains.ts. */ async aavePosition(account: Address): Promise { const pool = this.client.chainConfig.contracts.aaveV3Pool; if (!pool) throw new Error(`aave.position: Aave V3 Pool not configured on chain ${this.client.chainConfig.id}`); const tup = (await this.client.read({ address: pool, abi: AAVE_V3_POOL_ABI, functionName: "getUserAccountData", args: [account], })) as readonly [bigint, bigint, bigint, bigint, bigint, bigint]; return { account, totalCollateralBase: tup[0].toString(), totalDebtBase: tup[1].toString(), availableBorrowsBase: tup[2].toString(), currentLiquidationThresholdBps: Number(tup[3]), ltvBps: Number(tup[4]), healthFactor: tup[5].toString(), }; } /** Read an Aave V3 reserve's state for one asset. Returns the useful subset of getReserveData. */ async aaveMarket(asset: Address): Promise { const pool = this.client.chainConfig.contracts.aaveV3Pool; if (!pool) throw new Error(`aave.market: Aave V3 Pool not configured on chain ${this.client.chainConfig.id}`); const data = (await this.client.read({ address: pool, abi: AAVE_V3_POOL_ABI, functionName: "getReserveData", args: [asset], })) as { liquidityIndex: bigint; currentLiquidityRate: bigint; variableBorrowIndex: bigint; currentVariableBorrowRate: bigint; lastUpdateTimestamp: number; aTokenAddress: Address; variableDebtTokenAddress: Address; }; return { asset, aToken: data.aTokenAddress, variableDebtToken: data.variableDebtTokenAddress, liquidityRate: data.currentLiquidityRate.toString(), variableBorrowRate: data.currentVariableBorrowRate.toString(), liquidityIndex: data.liquidityIndex.toString(), variableBorrowIndex: data.variableBorrowIndex.toString(), lastUpdateTimestamp: Number(data.lastUpdateTimestamp), }; } /** Read an ERC20's name/symbol/decimals. Defensive: returns defaults rather than throwing. */ async tokenInfo(asset: Address): Promise { try { const [name, symbol, decimals] = await Promise.all([ this.client.read({ address: asset, abi: ERC20_METADATA_ABI, functionName: "name" }), this.client.read({ address: asset, abi: ERC20_METADATA_ABI, functionName: "symbol" }), this.client.read({ address: asset, abi: ERC20_METADATA_ABI, functionName: "decimals" }), ]); return { asset, name, symbol, decimals: Number(decimals) }; } catch { // Some tokens (e.g. early MKR) don't implement string-based name/symbol. Fall back gracefully. return { asset, name: "", symbol: "", decimals: 18 }; } } } // ── Tool dispatcher (used by the agent loop in W2-3) ───────────────────────── /** * Execute one tool call by name. The agent loop receives `{ name, input }` tool-use blocks from * the model and calls this dispatcher; the result is sent back as a `tool_result` block. * * Returns a JSON-serializable object that the model can read. Throws on unknown tools so the * agent loop can surface a clear error to the model (which can then retry or give up). */ export async function dispatchLucidlyTool( tools: ResearchToolsModule, name: string, input: Record, ): Promise { switch (name) { case "lucidly.policies": return tools.policies({ vault: input.vault as Address }); case "lucidly.holders": return tools.holders({ vault: input.vault as Address, limit: input.limit as number | undefined }); case "lucidly.depositors": return tools.depositors({ vault: input.vault as Address, limit: input.limit as number | undefined }); case "lucidly.navHistory": return tools.navHistory({ vault: input.vault as Address, limit: input.limit as number | undefined }); case "lucidly.policyRequests": return tools.policyRequests({ policyManager: input.policyManager as Address | undefined, limit: input.limit as number | undefined, }); case "morpho.market": return tools.morphoMarket({ loanToken: input.loanToken as Address, collateralToken: input.collateralToken as Address, oracle: input.oracle as Address, irm: input.irm as Address, lltv: input.lltv as string, }); case "morpho.position": return tools.morphoPosition( { loanToken: input.loanToken as Address, collateralToken: input.collateralToken as Address, oracle: input.oracle as Address, irm: input.irm as Address, lltv: input.lltv as string, }, input.account as Address, ); case "aave.position": return tools.aavePosition(input.account as Address); case "aave.market": return tools.aaveMarket(input.asset as Address); case "chain.tokenInfo": return tools.tokenInfo(input.asset as Address); default: throw new Error(`dispatchLucidlyTool: unknown tool "${name}"`); } }