/** * StrategyDecomposer — the heart of Lucidly Pro's "any strategy, any user" promise. * * Takes a free-text strategy idea ("I want delta-neutral USDC with a Pendle long + HL short") * and produces a structured `DecompositionResult` with: * • the nearest archetype from the curated corpus, * • a multi-chain leaf plan with explicit bridge edges, * • per-leaf verification status (verified | missing | needs_research), * • a `simulationReady` flag that is TRUE only when every leaf is verified. * * Architecture: * 1. Wrap the existing LLM provider stack (Anthropic/Groq/DeepSeek/OpenAI) — same provider * surface as ResearchAgent. * 2. Advertise the DECOMPOSER_TOOLS catalog (research + corpus + onchain + simulator). * 3. Run a strict system prompt that: * - forbids inventing leaves (must call verify_policy_id before emitting), * - enforces the homeChain vs positionChains topology distinction, * - mandates a structured JSON answer at the end, * - cites every claim (which corpus entry, which tool call). * 4. Parse the final JSON answer via zod, recompute simulationReady + confidence from the * verified leaves (the model can't lie), lint for structural issues, surface warnings. * * The agent is allowed to be "other" archetype — the corpus is a learning resource, not the * universe of valid strategies. The agent uses corpus entries as nearest-neighbour examples * but is free to compose novel plans when the user's idea doesn't fit a known archetype. */ import type { Address } from "viem"; import type { LLMProvider, AgentBlock, AgentMessage } from "./llm.js"; import { DECOMPOSER_TOOLS, dispatchDecomposerTool, type DecomposerToolContext, } from "./decomposerTools.js"; import { DecompositionResultSchema, recomputeReadiness, lintDecomposition, type DecompositionResult, } from "./decompositionTypes.js"; import type { ResearchToolsModule } from "./researchTools.js"; import type { SimulatorClient } from "./simulatorClient.js"; // ─── Public types ──────────────────────────────────────────────────────────── export interface StrategyDecomposerConfig { /** LLM provider — pick the right tier for the user's needs (Sonnet/Haiku/Groq/DeepSeek). */ provider: LLMProvider; /** Research module — required, provides catalog/policies/verify_policy_id. */ research: ResearchToolsModule; /** Simulator client — optional but STRONGLY recommended. When set, the agent can dry-run * candidate plans on an anvil fork before finalising. */ simulator?: SimulatorClient; /** Optional onchain reader — when set, the agent can do raw eth_call against any chain. */ rpcCall?: (chainId: number, target: Address, calldata: `0x${string}`, blockTag?: string) => Promise<`0x${string}`>; /** Optional native-balance reader. */ rpcBalance?: (chainId: number, address: Address, blockTag?: string) => Promise; /** Optional web search adapter — when set, the agent can lucidly.web_search for novel protocols * not in the corpus. Inject Brave / Tavily / DuckDuckGo / etc. with shape * (query, maxResults?) → [{title, url, snippet}]. */ webSearch?: (query: string, maxResults?: number) => Promise>; /** Optional Etherscan v2 unified API key. When set, lucidly.read_protocol_abi works across * every chain on the same key. Get one at https://etherscan.io/apis (free tier). */ etherscanApiKey?: string; /** Optional target vault address. When set, the agent will call verify_policy_id against this * specific vault to mark leaves as verified. When NOT set, the decomposer operates in DESIGN * MODE — every leaf is marked needs_research with a remediation step "deploy a vault first * and re-run with vaultAddress set". */ vaultAddress?: Address; /** Token budget — total input+output across the whole decomposition. Default 200k (corpus * entries are large; the budget needs headroom for re-included tool results). */ sessionTokenCap?: number; /** Iteration cap — max LLM round-trips. Default 12. */ maxIterations?: number; /** Max output tokens per individual model call. Default 8192 (final JSON answer is ~3-5kB * and we need headroom for the agent's reasoning + the answer). */ maxTokensPerCall?: number; /** Extra system-prompt text appended to the default. Useful for vault-specific context. */ extraSystemPrompt?: string; } export interface DecomposerToolCallLog { iteration: number; name: string; input: Record; resultJson: string; isError: boolean; } export interface DecomposerResult { /** Parsed structured decomposition. Null if the agent couldn't produce a valid JSON answer. */ decomposition: DecompositionResult | null; /** Raw text the agent emitted (last message). Useful for debugging when decomposition === null. */ rawText: string; /** Every tool call the agent made, in order. */ toolCalls: DecomposerToolCallLog[]; tokensUsed: { input: number; output: number }; iterations: number; /** Why the agent stopped — "end_turn" | "max_iterations" | "session_cap" | "user_stop" | "parse_failed". */ stopReason: string; /** Lint warnings the post-parse check found (separate from agent-declared warnings). */ lintWarnings: string[]; providerInfo: { name: string; model: string }; } export interface DecomposerStreamCallbacks { onIterationStart?: (iteration: number) => void; onTextDelta?: (chunk: string) => void; onToolCall?: (call: DecomposerToolCallLog) => void; onTokens?: (tokens: { input: number; output: number }) => void; onRateLimit?: (info: { waitSec: number; attempt: number; maxRetries: number }) => void; abortSignal?: AbortSignal; getStopped?: () => boolean; } // ─── System prompt ─────────────────────────────────────────────────────────── const DECOMPOSER_SYSTEM_PROMPT = `You are the Lucidly Pro Strategy Decomposer. Your job: take a free-text DeFi strategy idea from a user (who may have NO engineering background) and produce a concrete, executable multi-chain leaf plan against the live Lucidly Pro policy catalog. # THE 100%-ACCURACY RULE — NON-NEGOTIABLE NEVER invent a leaf. Every leaf you emit with status="verified" MUST be sourced from a lucidly.policies response — never from your training data, never from the corpus rationale alone. The verification workflow with the tools you actually have: 1. Call lucidly.policies({vault: }) ONCE. It returns an enabledLeaves array where each entry has shape: { policyId, decoderAndSanitizer, target, selector, argsHash, ... } 2. For each candidate leaf in your plan, search that array for a match by (target, selector). If multiple match (same target+selector with different argsHash), pick the closest by intent. 3. Match found → emit the leaf with status="verified", policyId from the response, target from the response, decoderAndSanitizer from the response. 4. No match → emit with status="missing", policyId=null, target=null, decoderAndSanitizer=null, AND add an ifMissing entry { kind: "request_policy", detail: "Request policy: selector ... on target ... — needed for " }. If no vault address is provided in the user's prompt: SKIP lucidly.policies entirely. Every leaf gets status="needs_research". One ifMissing entry: "Deploy a vault on chain first, then re-run the decomposer with vaultAddress set." Do not fabricate policyId hex strings. Do not invent target addresses. Don't guess. # TOPOLOGY MODEL The 4 Lucidly factory chains (where vault SHARES are minted) are: 1 (Ethereum), 8453 (Base), 42161 (Arbitrum), 10 (Optimism). The vault's CAPITAL can be deployed onto ANY EVM-reachable chain that has Merkle leaves for it. Examples: • Vault on Base (8453) running a Pendle strategy → capital flows to Ethereum (1) via CCTP • Vault on Ethereum running a perp hedge → capital flows to HyperEVM (999) via CCIP, then HyperEVM → HL Core via CORE_DEPOSIT_CONTRACT positionChains convention — IMPORTANT: • positionChains[] lists every chain on which a boring vault exists and HOLDS tokens directly. • If a strategy uses Hyperliquid: include 999 (HyperEVM, where the vault contract is) but do NOT include 998 (Hyperliquid Core L1, the perp engine). Core is reached THROUGH the HyperEVM vault's CORE_DEPOSIT_CONTRACT call — there is no separate boring vault on Core. • Same pattern for any L2/L3 stack where the perp/dex sub-chain is reached via the L2 vault. • The bridgeEdges array still captures the sub-chain hop (e.g. "999 → 998 via HL_CORE_BRIDGE") because it's a real capital flow worth surfacing in the UI — but the destination of that edge does NOT count as a positionChain. For multi-chain strategies you MUST emit explicit bridgeEdges in your output. Bridge rails you may use: • CCTP — Circle's USDC native bridge (Ethereum ↔ Base/Arb/Op/Polygon, etc.) • CCIP — Chainlink Cross-Chain Interop Protocol (general message + token bridge) • LZ — LayerZero (general message bridge, OFT for tokens) • HL_CORE_BRIDGE — Hyperliquid's HyperEVM ↔ Core internal bridge via CORE_DEPOSIT_CONTRACT (0x6b9e773128f453f5c2c60935ee2de2cbc5390a24) and CORE_WRITER (0x3333333333333333333333333333333333333333) • NATIVE — chain-specific native bridge (Optimism Standard Bridge, Arbitrum Bridge, etc.) # YOUR KNOWLEDGE BASE 1. STRATEGY CORPUS — 4 reference archetypes from production vaults. Use these as nearest-neighbour examples. Cite which corpus entry you drew from in nearestCorpusRef. Call lucidly.corpus_overview FIRST to see what's available, then lucidly.find_corpus_strategy to fetch full details of the closest match. 2. LIVE POLICY CATALOG — query via lucidly.policies(vault) to get the vault's enabled leaves. Each entry in enabledLeaves[] has { policyId, decoderAndSanitizer, target, selector, argsHash }. Match by (target, selector); pick the closest by argsHash if multiple match. There is NO separate verify_policy_id tool — lucidly.policies is the canonical source. 3. ONCHAIN READS — use lucidly.read_contract for live state (oracle staleness, market lltv, pool reserves, Pendle maturity, etc.) when the catalog doesn't carry it. 4. SIMULATOR DRY-RUN — when you've assembled a candidate plan, you SHOULD call lucidly.dry_run_plan against an anvil fork to confirm the calls execute. The simulator has no signing key — this is genuinely read-only. 5. NOVEL-PROTOCOL RESEARCH (when corpus is silent) — for protocols not in the corpus (e.g., Euler V2, Fluid, Spark, Hyperlend, Drift), use this stack INSTEAD of guessing from training data: • lucidly.web_search — find the protocol's official docs, factory addresses, market lists, current APYs. Limit ≤ 3 queries per decomposition. • lucidly.find_token — resolve token symbols to canonical addresses on a given chain. The SDK ships a curated registry of ~150 top DeFi tokens. Always use this rather than emitting an address from memory. • lucidly.read_protocol_abi — fetch verified ABI from Etherscan for a target contract. Use this to confirm exact selectors when training data may be stale (Euler V2's eVault selectors differ from V1, etc.). • Combined workflow for a novel protocol: web_search(' V2 docs') → identify the target contract address → read_protocol_abi(chainId, address) → use the verified selectors in your leaf emissions. 6. YOUR TRAINING KNOWLEDGE — last resort. If web_search / find_token / read_protocol_abi can't be wired (no API key configured), you may fall back to training data, but explicitly tag those leaves with rationale acknowledging the gap ("source: training data, may be stale — verify before deploy"). # REASONING WORKFLOW — TOKEN BUDGET MATTERS You have approximately 12 tool-call iterations and 400k tokens TOTAL. Tool results stack into every subsequent message — calling the same tool twice doubles the cost. Be deliberate. The typical successful run uses 3–6 tool calls, not 10+. # HARD CAPS ON RESEARCH TOOLS Per single decomposition session, you MAY NOT exceed: • lucidly.web_search — 5 calls maximum (use them deliberately: typically 1 for protocol overview, 1-2 for vault addresses, 1 for ABI/selector confirmation) • lucidly.read_protocol_abi — 3 calls maximum (proxy follow-through is automatic; one call per CONTRACT not one per attempt) • lucidly.find_token — 6 calls maximum (one per distinct asset is usually enough) • lucidly.find_corpus_strategy — 2 calls maximum (one nearest match is the goal) If you hit a cap and still don't have what you need, EMIT THE JSON with what you have, tag the gap in the leaf's rationale ("source: training data — web_search exhausted, recommend manual verification before deploy"), and STOP. A best-effort plan with honest gaps is more valuable than a stalled session that hits the cap and emits nothing. By iteration 8 at the latest, you MUST be writing the JSON answer. If you're still calling research tools at iteration 9, you've over-researched. STOP and emit. A good run looks like this: 1. UNDERSTAND (no tool). Read the user's idea. Identify: asset(s), target yield, risk hint, chain preference. Classify into one archetype (leveraged_carry, lp_hedge, yield_basket, delta_neutral, or "other" for novel). 2. EXAMPLE-MATCH (1–2 tool calls). Call lucidly.corpus_overview ONCE to see the landscape. Pick the SINGLE closest match. Call lucidly.find_corpus_strategy ONCE on that match to get its full playbook. Do NOT also call corpus_by_protocol + corpus_by_chain + corpus_cross_chain — they overlap and waste budget. Only fall back to a second corpus tool if the overview answer is genuinely insufficient. 3. ENUMERATE LEAVES (no tool, just reasoning). From the corpus match + your training knowledge of the protocols involved, list every onchain leaf the strategy needs. Capture (protocol, selector, target_role, rationale) per leaf. Don't worry about policyId yet — that's verification's job. 4. VERIFY (only if vaultAddress was provided — see operating-mode section below). Call lucidly.policies({vault}) ONCE. Cross-reference the returned enabledLeaves[] against your candidate leaves by (target, selector). Match → verified with the response's policyId. No match → missing. In DESIGN MODE (no vault), SKIP this entirely and mark all leaves needs_research. 5. BRIDGES. If positionChains > 1 or any leg targets a non-homeChain, emit bridgeEdges (CCTP / CCIP / LZ / HL_CORE_BRIDGE / NATIVE). Each bridge needs at least one sender leaf. 6. DRY-RUN (optional, only if leaves verified with bound calldata). Skip in design mode. 7. EMIT FINAL ANSWER. Reply with a single JSON document matching the DecompositionResult schema. NO prose around it. NO markdown fences (no \`\`\`json wrapper). Just the raw JSON object as your entire final message. ANTI-PATTERNS to avoid: • Calling lucidly.policies({vault: "0x0000…0000"}) — that's a null vault, the answer is always empty. Don't do this. • Calling find_corpus_strategy more than once. • Calling corpus_by_protocol / corpus_by_chain / corpus_cross_chain in the same session — pick AT MOST one. • Re-fetching anything you've already seen in this conversation. • Long prose reasoning between tool calls — keep it under 2 sentences per iteration. Save your output budget for the final JSON. # OUTPUT FORMAT — STRICT JSON SCHEMA After all tool calls, your FINAL reply must be a single JSON object (no markdown fences, no prose) with this shape: { "apiVersion": "1", "requestId": null, "originalPrompt": "", "planId": "", "displayName": "", "archetype": "leveraged_carry" | "lp_hedge" | "yield_basket" | "delta_neutral" | "other", "nearestCorpusRef": "", "summary": "", "homeChain": <1|8453|42161|10>, "positionChains": [], "bridgeEdges": [ { "fromChain": , "toChain": , "rail": "CCTP"|"CCIP"|"LZ"|"HL_CORE_BRIDGE"|"NATIVE", "asset": "", "senderLeaves": [...], "finality": "instant"|"~12min"|"~10-15min", "rationale": "..." } ], "legs": [ { "chain": , "chainName": "ethereum"|"base"|"arbitrum"|"optimism"|"hyperevm"|..., "kind": "onchain"|"api", "purpose": "open_position"|"rebalance"|"close_position"|..., "leaves": [ { "policyId": "" | null, "protocol": "", "selector": "", "target": "
" | null, "decoderAndSanitizer": "
" | null, "status": "verified" | "missing" | "needs_research", "rationale": "", "calldata": "" | null, "value": "" | null } ], "rationale": "" } ], "confidence": 0.0-1.0, "confidenceBreakdown": { "archetypeMatch": 0.0-1.0, "leafCoverage": 0.0-1.0, "bridgeCoverage": 0.0-1.0 }, "warnings": ["...]", "simulationReady": , "ifMissing": [ { "kind": "request_policy"|"enable_existing_policy"|"research_protocol"|"alternative_route", "detail": "", "suggestedLink": "" } ], "riskFactors": { "leverage": "...", "oracle": "...", "custody": "...", "bridge": "...", "smartContract": "..." }, "citations": [ { "source": "corpus"|"policy_registry"|"blockchain_read"|"web_search"|"llm_knowledge", "detail": "..." } ], "generatedAt": "" } Notes: • policyId + target + decoderAndSanitizer are nullable — null when status is missing or needs_research. • calldata is nullable — null when params aren't bound yet (the user supplies them later in the UI). • simulationReady will be RECOMPUTED after you submit — don't try to lie about it. Set it honestly based on whether every leaf you emitted is verified. • The user trusts you. A wrong plan that gets executed costs them real money. When in doubt, mark a leaf "needs_research" and add an ifMissing entry rather than a confident-looking but wrong policyId. You have access to these tool families: lucidly.* (research + corpus + onchain + simulator). Use them liberally — the user is willing to wait 15-30 seconds for a thoughtful plan, but they hate being misled.`; // ─── StrategyDecomposer ────────────────────────────────────────────────────── export class StrategyDecomposer { private readonly provider: LLMProvider; private readonly ctx: DecomposerToolContext; private readonly maxTokensPerCall: number; private readonly sessionTokenCap: number; private readonly maxIterations: number; private readonly systemPrompt: string; constructor(cfg: StrategyDecomposerConfig) { if (!cfg.provider) throw new Error("StrategyDecomposer: provider is required"); if (!cfg.research) throw new Error("StrategyDecomposer: research module is required"); this.provider = cfg.provider; this.ctx = { research: cfg.research, simulator: cfg.simulator, rpcCall: cfg.rpcCall, rpcBalance: cfg.rpcBalance, webSearch: cfg.webSearch, etherscanApiKey: cfg.etherscanApiKey, }; this.maxTokensPerCall = cfg.maxTokensPerCall ?? 8192; // Cap raised to 400k to accommodate research-heavy DESIGN-mode runs where the agent // chains web_search + read_protocol_abi against unfamiliar protocols. Search results // and ABIs accumulate into every subsequent message — the budget needs headroom. this.sessionTokenCap = cfg.sessionTokenCap ?? 400_000; this.maxIterations = cfg.maxIterations ?? 12; // Compose the prompt: base + mode (verify vs design) + any caller-supplied extra. const modeNote = cfg.vaultAddress ? `\n\n# OPERATING MODE — VERIFY (vault provided)\n\nThe target vault is ${cfg.vaultAddress}. Call lucidly.policies({vault: "${cfg.vaultAddress}"}) ONCE. From the returned enabledLeaves[]: match each of your candidate leaves by (target, selector). Match → verified with the response's policyId + decoderAndSanitizer + target. No match → status="missing", policyId=null, target=null, decoderAndSanitizer=null, plus an ifMissing entry naming the (selector, target_role) to request.` : `\n\n# OPERATING MODE — DESIGN (no vault provided)\n\nNo vault address is in the user's prompt. You CANNOT verify any leaf — verification requires a deployed vault to check against. Operate in DESIGN MODE:\n • ALLOWED tools: lucidly.corpus_overview, lucidly.find_corpus_strategy, lucidly.corpus_by_archetype, lucidly.corpus_by_protocol, lucidly.corpus_by_chain, lucidly.corpus_cross_chain (always). PLUS lucidly.web_search, lucidly.find_token, lucidly.read_protocol_abi when the user mentions a protocol the corpus doesn't cover (Euler, Fluid, Spark, Drift, etc.) — these ground your novel-protocol leaves in current reality instead of training data guesses.\n • FORBIDDEN tools: lucidly.policies, lucidly.list_vaults, lucidly.find_vaults_with_protocol, lucidly.holders, lucidly.depositors, lucidly.read_contract, lucidly.read_balance, lucidly.read_erc20_balance, lucidly.dry_run_plan, lucidly.policyRequests — they all require a target vault or live execution that's irrelevant when sketching a plan.\n • Spend tools deliberately: typically 1-2 corpus tools + 0-3 research tools = 2-5 total. More than that wastes the budget.\n • Emit every leaf with status="needs_research", policyId=null, target=null, decoderAndSanitizer=null. The target field stays null even if find_token returned the address — target on a leaf means "the contract this Merkle leaf authorises calls to", which is only meaningful once verified on a real vault. Put the find_token / web_search / read_protocol_abi findings in the leaf's rationale text instead.\n • In ifMissing, add ONE entry: { kind: "request_policy", detail: "Deploy a vault on chain X first, then re-run the decomposer with vaultAddress set to that vault. The leaves below describe the policies your vault will need." }\n • You still produce the FULL plan (topology, legs, leaf families, bridge edges, risk register, citations) — the user uses this to decide whether to deploy a vault for it.`; this.systemPrompt = DECOMPOSER_SYSTEM_PROMPT + modeNote + (cfg.extraSystemPrompt ? "\n\n# Extra context\n\n" + cfg.extraSystemPrompt : ""); } get providerInfo(): { name: string; model: string } { return { name: this.provider.name, model: this.provider.model }; } /** Run a decomposition end-to-end with no UI feedback. */ async decompose(idea: string): Promise { return this.decomposeStream(idea, {}); } /** Run a decomposition with streaming callbacks for live UI feedback. */ async decomposeStream(idea: string, callbacks: DecomposerStreamCallbacks): Promise { if (!idea || !idea.trim()) throw new Error("StrategyDecomposer.decomposeStream: empty idea"); const { onIterationStart, onTextDelta, onToolCall, onTokens, onRateLimit, abortSignal, getStopped } = callbacks; const messages: AgentMessage[] = [ { role: "user", content: [{ type: "text", text: idea }] }, ]; const toolCalls: DecomposerToolCallLog[] = []; let totalInput = 0; let totalOutput = 0; let iterations = 0; let lastText = ""; let stopReasonStr = "end_turn"; // Runtime-enforced per-session tool caps. Prompt language alone wasn't enough — real DeepSeek // runs ignored the prompt caps and burned the whole iteration budget on research. Hard counts // backed by the dispatcher (return synthetic error after the cap is exceeded) force the agent // to converge on an answer instead of looping forever. // web_search bumped 3→5 in 0.2.15 because novel-protocol prompts (Euler, Fluid, etc.) often // need 2-3 searches to find vault addresses + 1-2 to find ABI patterns. 5 is the sweet spot // between research quality and token cost. const TOOL_CAPS: Record = { "lucidly.web_search": 5, "lucidly.read_protocol_abi": 3, "lucidly.find_token": 6, "lucidly.find_corpus_strategy": 2, }; const toolCounts: Record = {}; const stopped = () => (getStopped ? getStopped() : false); for (let i = 0; i < this.maxIterations; i++) { if (stopped()) { stopReasonStr = "user_stop"; break; } if (totalInput + totalOutput >= this.sessionTokenCap) { stopReasonStr = "session_cap"; break; } iterations = i + 1; onIterationStart?.(iterations); // Strip tools at the final 2 iterations — agent literally cannot tool-call, must emit text. // This forces JSON output and prevents the "kept researching, ran out of iterations" trap // we saw in the real Euler run. const isForcedEmitIteration = i >= this.maxIterations - 2; const effectiveTools = isForcedEmitIteration ? [] : DECOMPOSER_TOOLS; // When tools are stripped, DeepSeek (and to a lesser extent other models) sometimes ignore // the empty tools list and emit their internal tool-call markup as text (e.g. DSML tags). // Counter this with an absolutely explicit "TOOLS ARE GONE, JSON ONLY" directive appended // at the END of the system prompt — model attention biases toward the last instructions. const FORCED_EMIT_DIRECTIVE = `\n\n# ⚠ FINAL ITERATION — TOOLS UNAVAILABLE\n\nThis is your LAST opportunity to respond. Tool-calling is disabled for this iteration. Your previous research is sufficient to produce a best-effort plan.\n\nYou MUST respond with EXACTLY one valid JSON object matching the DecompositionResult schema. NOTHING ELSE.\n\nProhibited in this response:\n • Any tool calls (including DSML-style markup like <||DSML||tool_calls>, function-call XML, or any internal tool-call format your training expects)\n • Markdown fences (no \`\`\`json wrapper)\n • Prose introduction or commentary\n • Any text before the opening { or after the closing }\n\nFor any leaf where you didn't have enough research budget to verify, mark it status="needs_research" and add an ifMissing entry. That is the honest, correct behavior. A best-effort plan with honest gaps is the deliverable.\n\nSTART YOUR RESPONSE WITH { AND END IT WITH }. NOTHING ELSE.`; const effectiveSystem = isForcedEmitIteration ? this.systemPrompt + FORCED_EMIT_DIRECTIVE : this.systemPrompt; let response; try { response = await this.provider.complete({ system: effectiveSystem, tools: effectiveTools, messages, maxTokens: this.maxTokensPerCall, onTextDelta, onRateLimit, abortSignal, }); } catch (e) { if ((e as { name?: string })?.name === "AbortError" || stopped()) { stopReasonStr = "user_stop"; break; } throw e; } 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) { // Model has stopped requesting tools — its content blocks contain the final answer. lastText = collectText(response.content); stopReasonStr = "end_turn"; break; } // Dispatch every tool call, batch the results back as one user turn. const toolResultBlocks: Extract[] = []; for (const tc of toolCallBlocks) { if (stopped()) { stopReasonStr = "user_stop"; break; } let resultJson: string; let isError = false; // ── Per-session tool cap enforcement ──────────────────────────────────────────────── const cap = TOOL_CAPS[tc.name]; const used = toolCounts[tc.name] ?? 0; if (cap !== undefined && used >= cap) { // Don't even call the dispatcher — synthesize an error result that tells the agent to stop. toolCounts[tc.name] = used + 1; isError = true; resultJson = JSON.stringify({ error: "tool_call_cap_exceeded", tool: tc.name, calls: used + 1, cap, hint: `You've called ${tc.name} ${used + 1} times this session (cap: ${cap}). NO further calls to this tool will be honored. Stop researching and EMIT THE FINAL JSON ANSWER NOW with what you have. Tag any unverified leaves in their rationale as "source: training data — research budget exhausted, verify before deploy".`, }); const cappedLog: DecomposerToolCallLog = { iteration: iterations, name: tc.name, input: tc.input as Record, resultJson, isError, }; toolCalls.push(cappedLog); onToolCall?.(cappedLog); toolResultBlocks.push({ type: "tool_result", toolCallId: tc.id, content: resultJson, isError }); continue; } toolCounts[tc.name] = used + 1; try { const result = await dispatchDecomposerTool(this.ctx, tc.name, tc.input as Record); resultJson = JSON.stringify(result, jsonReplacer); // If the tool returned an { error } shape, mark it so the agent treats it appropriately. if (result && typeof result === "object" && "error" in (result as Record)) isError = true; } catch (e) { isError = true; resultJson = JSON.stringify({ error: e instanceof Error ? e.message : String(e) }); } const log: DecomposerToolCallLog = { iteration: iterations, name: tc.name, input: tc.input as Record, resultJson, isError, }; toolCalls.push(log); onToolCall?.(log); toolResultBlocks.push({ type: "tool_result", toolCallId: tc.id, content: resultJson, isError, }); } messages.push({ role: "user", content: toolResultBlocks }); } if (stopReasonStr === "end_turn") { // We have a final text answer; try to parse it. // do nothing further — fall through to parse below. } else if (iterations >= this.maxIterations) { stopReasonStr = "max_iterations"; } // ── Parse the final answer ──────────────────────────────────────────────── const parsed = tryParseJson(lastText); if (!parsed) { return { decomposition: null, rawText: lastText, toolCalls, tokensUsed: { input: totalInput, output: totalOutput }, iterations, stopReason: stopReasonStr === "end_turn" ? "parse_failed" : stopReasonStr, lintWarnings: ["agent did not emit a valid JSON answer"], providerInfo: this.providerInfo, }; } // Normalise: agents sometimes wrap in {decomposition: {...}} or omit requestId. let candidate: unknown = parsed; if ( candidate && typeof candidate === "object" && "decomposition" in (candidate as Record) && Object.keys(candidate as Record).length === 1 ) { candidate = (candidate as Record).decomposition; } if (candidate && typeof candidate === "object") { const obj = candidate as Record; if (obj.requestId === undefined) obj.requestId = null; if (obj.originalPrompt === undefined) obj.originalPrompt = idea; if (obj.generatedAt === undefined) obj.generatedAt = new Date().toISOString(); } const zr = DecompositionResultSchema.safeParse(candidate); if (!zr.success) { return { decomposition: null, rawText: lastText, toolCalls, tokensUsed: { input: totalInput, output: totalOutput }, iterations, stopReason: "parse_failed", lintWarnings: [ "agent JSON failed schema validation", ...zr.error.issues.slice(0, 5).map((iss) => `${iss.path.join(".")}: ${iss.message}`), ], providerInfo: this.providerInfo, }; } // ── Recompute readiness / lint ──────────────────────────────────────────── const result: DecompositionResult = zr.data; const readiness = recomputeReadiness(result); result.simulationReady = readiness.simulationReady; result.confidence = readiness.confidence; result.confidenceBreakdown = readiness.confidenceBreakdown; const lintWarnings = lintDecomposition(result); // Merge any lint warnings into the agent's warnings array (deduped). for (const w of lintWarnings) { if (!result.warnings.includes(w)) result.warnings.push(w); } return { decomposition: result, rawText: lastText, toolCalls, tokensUsed: { input: totalInput, output: totalOutput }, iterations, stopReason: stopReasonStr, lintWarnings, providerInfo: this.providerInfo, }; } } // ─── Helpers ───────────────────────────────────────────────────────────────── function collectText(blocks: AgentBlock[]): string { return blocks .filter((b): b is Extract => b.type === "text") .map((b) => b.text) .join("\n") .trim(); } function jsonReplacer(_k: string, v: unknown): unknown { if (typeof v === "bigint") return v.toString(); return v; } /** Try to parse the agent's final text as JSON. Strategies in order of preference: * 1. Direct JSON.parse on the trimmed text (perfect output) * 2. Match ```json or ``` fences ANYWHERE in the text (handles prose+fence) * 3. Brace-counting scan from each '{' to find the first balanced JSON object that parses * (handles cases where the agent emitted prose and JSON without fences, or DSML markup * mixed with JSON) * 4. Last-resort first-{-to-last-} slice (catches trailing-comma-style minor errors) * Returns null only if every strategy fails. */ function tryParseJson(text: string): unknown | null { if (!text) return null; const trimmed = text.trim(); // 1. Direct parse. try { return JSON.parse(trimmed); } catch { /* fall through */ } // 2. Find ALL ```json or ``` fences anywhere in the text (not just whole-text). The agent // often emits prose like "Here's the plan:\n\n```json\n{...}\n```" — the JSON is inside // the fence regardless of what surrounds it. const fenceRegex = /```(?:json|JSON)?\s*\n?([\s\S]*?)\n?```/g; let fenceMatch: RegExpExecArray | null; while ((fenceMatch = fenceRegex.exec(trimmed)) !== null) { const inner = fenceMatch[1]?.trim(); if (!inner) continue; try { return JSON.parse(inner); } catch { /* try next fence */ } } // 3. Brace-counting scan from each '{' position. For each candidate opening brace, walk // forward counting braces (skipping over content inside strings) until we hit a balanced // close. Try JSON.parse on the slice. First one that parses wins. for (let i = 0; i < trimmed.length; i++) { if (trimmed[i] !== "{") continue; const end = findBalancedClose(trimmed, i); if (end < 0) continue; const slice = trimmed.slice(i, end + 1); try { const parsed = JSON.parse(slice); // Sanity check — we want an object, not just any JSON. Skip primitives. if (parsed && typeof parsed === "object") return parsed; } catch { /* try next opening brace */ } } // 4. Last-resort: first-{-to-last-}. Catches some malformed cases the brace counter rejects. const firstBrace = trimmed.indexOf("{"); const lastBrace = trimmed.lastIndexOf("}"); if (firstBrace >= 0 && lastBrace > firstBrace) { const slice = trimmed.slice(firstBrace, lastBrace + 1); try { return JSON.parse(slice); } catch { /* fall through */ } } return null; } /** Walk forward from `openIdx` counting brace depth, respecting string literals. Returns the * index of the matching closing brace, or -1 if none found before end-of-string. */ function findBalancedClose(text: string, openIdx: number): number { let depth = 0; let inString = false; let stringChar: string | null = null; let escape = false; for (let i = openIdx; i < text.length; i++) { const ch = text[i]!; if (escape) { escape = false; continue; } if (inString) { if (ch === "\\") { escape = true; continue; } if (ch === stringChar) { inString = false; stringChar = null; } continue; } if (ch === '"' || ch === "'") { inString = true; stringChar = ch; continue; } if (ch === "{") depth++; else if (ch === "}") { depth--; if (depth === 0) return i; } } return -1; }