From cf3fc27f66628f60d6c8a908d4b4c4edaece19fd Mon Sep 17 00:00:00 2001 From: Ryan Buchmayer Date: Thu, 4 Jun 2026 11:15:00 -0700 Subject: [PATCH 1/4] docs(articles): add LangChain VC investment memo agent guide An auditable, citation-grounded VC research agent built with LangGraph and the Perplexity Agent API (built-in web_search/finance_search), plus a LangSmith eval harness comparing search providers. Self-contained code under scripts/memo/. --- .../langchain-vc-memo-agent/README.mdx | 176 ++++++++++++++++ .../scripts/.env.example | 11 + .../scripts/memo/__init__.py | 0 .../scripts/memo/__main__.py | 5 + .../scripts/memo/_compat.py | 72 +++++++ .../scripts/memo/compare.py | 48 +++++ .../scripts/memo/eval_dataset.py | 7 + .../scripts/memo/evaluators.py | 61 ++++++ .../scripts/memo/graph.py | 192 ++++++++++++++++++ .../scripts/memo/main.py | 23 +++ .../scripts/memo/profiles.py | 115 +++++++++++ .../scripts/requirements.txt | 9 + static/img/langchain-vc-memo-architecture.png | Bin 0 -> 18665 bytes 13 files changed, 719 insertions(+) create mode 100644 docs/articles/langchain-vc-memo-agent/README.mdx create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/.env.example create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/memo/__init__.py create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/memo/__main__.py create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/memo/_compat.py create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/memo/compare.py create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/memo/eval_dataset.py create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/memo/evaluators.py create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/memo/graph.py create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/memo/main.py create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/memo/profiles.py create mode 100644 docs/articles/langchain-vc-memo-agent/scripts/requirements.txt create mode 100644 static/img/langchain-vc-memo-architecture.png diff --git a/docs/articles/langchain-vc-memo-agent/README.mdx b/docs/articles/langchain-vc-memo-agent/README.mdx new file mode 100644 index 0000000..a0cc5a0 --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/README.mdx @@ -0,0 +1,176 @@ +--- +title: VC Investment Memo Agent with LangGraph +description: Build an auditable, citation-grounded VC research agent with LangGraph and the Perplexity Agent API, and pick the best search provider with a LangSmith eval harness. +sidebar_position: 3 +keywords: [langgraph, langchain, langsmith, agent api, web_search, finance_search, investment memo, multi-agent, citations, evaluation] +--- + +# Building an Auditable VC Investment Memo Agent with LangGraph and the Perplexity Agent API + +## Overview + +This guide builds an agent that takes a company name and returns a seven-section VC investment memo — Snapshot, Team, Financials, Product, Market, Risks, and a Thesis ending in a one-line recommendation — where **every claim is traced back to a primary source**. + +It runs on the [Perplexity Agent API](https://docs.perplexity.ai/docs/agent-api/tools) and its built-in `web_search` and `finance_search` tools, orchestrated with [LangGraph](https://langchain-ai.github.io/langgraph/), and evaluated in [LangSmith](https://docs.langchain.com/langsmith/home). The whole build runs in about ninety seconds for roughly $0.40 per memo. + +The design lesson generalizes well beyond finance: **splitting search from synthesis is a cheap, structural reliability fix for a research agent.** Four research nodes fan out in parallel, each calling the Agent API with its own tools; a final synthesizer node has *no tools* and can only cite evidence the research nodes already gathered — so the memo cannot invent a source. + +![Company name flows into START, fans out to four parallel research nodes (team, financials, product, market), which all feed a single synthesizer node that produces the memo at END.](../../static/img/langchain-vc-memo-architecture.png) + +## Features + +- **Parallel research fan-out** — four focused research nodes (team, financials, product, market) run concurrently, each with its own tools and search budget. +- **Tool-less synthesizer** — the final memo is composed only from upstream evidence, a structural guard against fabricated citations. +- **Built-in Agent API tools** — `web_search` and `finance_search` work out of the box; no client-side search plumbing for the core agent. +- **Auditable in LangSmith** — every node's tool calls and outputs are captured, so any claim traces back to the search result that produced it. +- **Provider eval harness** — a LangSmith comparison that scores search providers on primary-source rate, financial-concept coverage, latency, and cost. + +## Prerequisites + +- Python 3.10+ +- A [Perplexity API key](https://docs.perplexity.ai/docs/admin/api-key-management) (`PPLX_API_KEY`) +- A [LangSmith API key](https://docs.smith.langchain.com/) for tracing and evaluation +- (Section 2 only) [Parallel](https://platform.parallel.ai/) and [Exa](https://dashboard.exa.ai/) API keys + +## Installation + +```bash +pip install -r scripts/requirements.txt +``` + +```bash +# ChatPerplexity reads PPLX_API_KEY. +export PPLX_API_KEY="pplx-..." +export LANGSMITH_API_KEY="ls__..." +export LANGSMITH_TRACING="true" # capture every node's tool calls end-to-end +``` + +## How it works + +### Graph state + +Each research node reads `company` from the shared state and writes its findings into `research_output`; a reducer merges the parallel writes. + +```python +def merge_research_output(left: dict[str, str], right: dict[str, str]) -> dict[str, str]: + """Each research node returns {"
": "..."}; merge into one dict.""" + return {**(left or {}), **(right or {})} + + +class MemoState(TypedDict): + company: str + research_output: Annotated[dict[str, str], merge_research_output] + memo: str +``` + +### Models and tools + +The Agent API exposes Perplexity's built-in tools directly. The financials node adds `finance_search`; the rest use `web_search`. `max_steps` caps each node's internal search loop. + +```python +def _agent_model(model: str) -> ChatPerplexity: + # The Responses (Agent) API ignores sampling params like temperature, so we omit it. + return ChatPerplexity(model=model, use_responses_api=True) + +TEAM_TOOLS = [{"type": "web_search", "filters": {"search_recency_filter": "year"}}] +FINANCIALS_TOOLS = [{"type": "finance_search"}, {"type": "web_search"}] +PRODUCT_TOOLS = MARKET_TOOLS = [{"type": "web_search"}] +``` + +### The synthesizer + +The synthesizer has no tools. It cites only from the research outputs, so anything in the memo is grounded in research the nodes actually did. + +```python +def synthesizer_node(state: MemoState) -> dict[str, str]: + """Combine all research outputs into the final memo. No tools attached.""" + research_output_block = "\n\n".join( + f"## Research output: {name}\n\n{body}" + for name, body in sorted(state["research_output"].items()) + ) + msg = SYNTHESIZER_MODEL.invoke([ + {"role": "system", "content": SYNTH_PROMPT.format(company=state["company"])}, + {"role": "user", "content": f"Company: {state['company']}\n\n{research_output_block}"}, + ]) + return {"memo": msg.content} +``` + +### Wiring the graph + +Four research nodes fan out from `START` in parallel and converge on the synthesizer — a few lines of graph wiring. + +```python +def build_graph(): + g = StateGraph(MemoState) + for section in ("team", "financials", "product", "market"): + g.add_node(section, research_node(section)) + g.add_edge(START, section) # fan out from START, in parallel + g.add_edge(section, "synthesizer") # ...and back into the synthesizer + g.add_node("synthesizer", synthesizer_node) + g.add_edge("synthesizer", END) + return g.compile() +``` + +### Running it + +```bash +python -m memo --company "Anthropic" +``` + +The full agent lives in `scripts/memo/` (`graph.py`, `main.py`). + +## Choosing a search provider + +A common question when building a research agent is which search provider to use. `scripts/memo/profiles.py` runs the same graph with three swappable client-side search tools — `PerplexitySearchResults`, `ParallelSearchTool`, `ExaSearchResults` — so the same metrics apply to each, and `scripts/memo/compare.py` scores them in LangSmith. + +Two custom evaluators score memo quality, alongside LangSmith's built-in latency and cost: + +- `primary_source_rate` — share of citations from primary sources (IR pages, SEC, official press) rather than aggregators. +- `financial_concept_coverage` — whether the Financials section covers valuation, revenue, funding, and operating metrics. + +```bash +python -m memo.compare +``` + +### Results + +Scored across ten public and private companies on `openai/gpt-5.5`: + +| Metric | Perplexity | Parallel | Exa | +| --- | --- | --- | --- | +| Primary-source rate | **1.00** | 0.82 | 0.85 | +| Financial-concept coverage | **0.70** | 0.70 | 0.50 | +| Latency p50 (s/memo) | **91** | 192 | 143 | +| Cost (USD/memo) | **$0.38** | $0.60 | $0.67 | + +Perplexity posted a perfect primary-source rate, the fastest memos, and the lowest cost per run, while tying for the best financial-concept coverage. For an output that has to withstand scrutiny — a VC memo does — that mix of source quality and speed is hard to beat. + +## Code explanation + +``` +scripts/ +├── requirements.txt +├── .env.example +└── memo/ + ├── graph.py # typed state, parallel research nodes, tool-less synthesizer, build_graph() + ├── main.py # CLI entrypoint (python -m memo --company "...") + ├── profiles.py # section 2: the three swappable provider profiles + ├── evaluators.py # LangSmith evaluators + ├── eval_dataset.py # companies used as eval inputs + ├── compare.py # runs the LangSmith comparison + └── _compat.py # temporary shim (see Limitations) +``` + +## Links + +- [Perplexity Agent API tools](https://docs.perplexity.ai/docs/agent-api/tools) +- [Finance Search](https://docs.perplexity.ai/docs/agent-api/finance-search) +- [LangChain Perplexity provider](https://docs.langchain.com/oss/python/integrations/providers/perplexity) +- [LangGraph](https://langchain-ai.github.io/langgraph/) +- [LangSmith evaluation](https://docs.langchain.com/langsmith/evaluation) + +## Limitations + +- **Client-side tool loop shim.** Section 2 drives a client-side function-tool loop to compare external search providers. The released `langchain-perplexity` (≤ 1.3.1) does not serialize `ToolMessage` / `AIMessage.tool_calls` back to the API, so `scripts/memo/_compat.py` patches the two converters. Remove it once the upstream fix ships. The core agent (Section 1) uses Perplexity's built-in server-side tools and needs no shim. +- **Section 2 uses web search only.** The provider comparison swaps web-search backends, so the Perplexity profile there does not use `finance_search` — financial-concept coverage for private companies can vary run to run. +- **The seven-section template and PASS / TRACK / ADVANCE / LEAD scale are conventions defined for this agent** — swap in your own firm's format. diff --git a/docs/articles/langchain-vc-memo-agent/scripts/.env.example b/docs/articles/langchain-vc-memo-agent/scripts/.env.example new file mode 100644 index 0000000..3f0718c --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/scripts/.env.example @@ -0,0 +1,11 @@ +# ChatPerplexity reads PPLX_API_KEY. +PPLX_API_KEY=pplx-... + +# LangSmith — tracing is on from the start so every node's tool calls and +# token usage are captured end-to-end. +LANGSMITH_API_KEY=ls__... +LANGSMITH_TRACING=true + +# Section 2 only +PARALLEL_API_KEY=... +EXA_API_KEY=... diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/__init__.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/__main__.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/__main__.py new file mode 100644 index 0000000..bcbfde6 --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/scripts/memo/__main__.py @@ -0,0 +1,5 @@ +from .main import main + + +if __name__ == "__main__": + main() diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/_compat.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/_compat.py new file mode 100644 index 0000000..bd1e883 --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/scripts/memo/_compat.py @@ -0,0 +1,72 @@ +"""Temporary shim: client-side tool-calling round-trip for ChatPerplexity. + +Section 2 drives a client-side function-tool loop (bind a search tool, let the +model emit tool_calls, feed results back as ToolMessages). The released +``langchain-perplexity`` (<= 1.3.1, i.e. the merged langchain-ai/langchain#37359) +cannot serialize that loop back to the API: + + * ``_convert_message_to_dict`` has no ``ToolMessage`` branch (raises + ``TypeError: Got unknown type``) and its ``AIMessage`` branch silently drops + ``tool_calls``; + * ``_to_responses_payload`` forwards Chat-Completions-style dicts unchanged, but + the Responses (Agent) API needs typed ``function_call`` / ``function_call_output`` + input items. + +``langchain-openai`` already handles all of this; the Perplexity package was +modeled on it but never inherited the branches. Importing this module monkey-patches +both methods so the loop round-trips. Remove it once the upstream fix ships. +""" +from __future__ import annotations + +import json + +import langchain_perplexity.chat_models as _cm +from langchain_core.messages import AIMessage, ToolMessage + +_orig_convert = _cm.ChatPerplexity._convert_message_to_dict +_orig_responses = _cm.ChatPerplexity._to_responses_payload + + +def _convert_message_to_dict(self, message): + """Add the ToolMessage / AIMessage.tool_calls branches (mirrors langchain-openai).""" + if isinstance(message, ToolMessage): + return {"role": "tool", "content": message.content, + "tool_call_id": message.tool_call_id} + if isinstance(message, AIMessage): + out = {"role": "assistant", "content": message.content or ""} + if message.tool_calls: + out["tool_calls"] = [ + {"id": tc["id"], "type": "function", + "function": {"name": tc["name"], "arguments": json.dumps(tc["args"])}} + for tc in message.tool_calls + ] + return out + return _orig_convert(self, message) + + +def _to_responses_payload(self, message_dicts, params, *, user_set_keys=None): + """Translate Chat-Completions tool turns into Responses-API typed input items.""" + payload = _orig_responses(self, message_dicts, params, user_set_keys=user_set_keys) + translated = [] + for m in payload.get("input", []): + if not isinstance(m, dict): + translated.append(m) + elif m.get("role") == "assistant" and m.get("tool_calls"): + if m.get("content"): + translated.append({"role": "assistant", "content": m["content"]}) + for tc in m["tool_calls"]: + translated.append({"type": "function_call", "call_id": tc["id"], + "name": tc["function"]["name"], + "arguments": tc["function"]["arguments"]}) + elif m.get("role") == "tool": + out = m["content"] if isinstance(m["content"], str) else json.dumps(m["content"]) + translated.append({"type": "function_call_output", + "call_id": m["tool_call_id"], "output": out}) + else: + translated.append(m) + payload["input"] = translated + return payload + + +_cm.ChatPerplexity._convert_message_to_dict = _convert_message_to_dict +_cm.ChatPerplexity._to_responses_payload = _to_responses_payload diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/compare.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/compare.py new file mode 100644 index 0000000..e30895a --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/scripts/memo/compare.py @@ -0,0 +1,48 @@ +import asyncio +from langsmith import Client +from langsmith.evaluation import evaluate + +from .eval_dataset import EVAL_COMPANIES +from .evaluators import primary_source_rate, financial_concept_coverage +from .profiles import PROFILES, ProviderProfile + +DATASET_NAME = "vc-memo-eval-v2" +client = Client() + + +def upload_dataset() -> None: + """Create the LangSmith eval dataset from EVAL_COMPANIES if it doesn't already exist.""" + if any(d.name == DATASET_NAME for d in client.list_datasets()): + return + dataset = client.create_dataset(DATASET_NAME, description="VC memo evaluation") + for company in EVAL_COMPANIES: + client.create_example( + inputs={"company": company}, + outputs={}, + dataset_id=dataset.id, + ) + + +async def _run_one(company: str, profile: ProviderProfile) -> dict: + """Run the memo graph for one company under the given provider profile.""" + graph = profile.build_graph() + final = await graph.ainvoke({"company": company, "research_output": {}, "memo": ""}) + return {"memo_md": final["memo"]} + + +def main() -> None: + """Upload the dataset and run a LangSmith evaluation for each provider profile.""" + upload_dataset() + for name in ("perplexity", "parallel", "exa"): + profile = PROFILES[name] + evaluate( + lambda inputs, profile=profile: asyncio.run(_run_one(inputs["company"], profile)), + data=DATASET_NAME, + evaluators=[primary_source_rate, financial_concept_coverage], + experiment_prefix=f"memo-{name}", + max_concurrency=2, + ) + + +if __name__ == "__main__": + main() diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/eval_dataset.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/eval_dataset.py new file mode 100644 index 0000000..bf5dfb5 --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/scripts/memo/eval_dataset.py @@ -0,0 +1,7 @@ +# A mix of public and private companies across sectors. The evaluators below +# are search-quality metrics — no per-company ground truth required, so extend +# this with your own targets freely. +EVAL_COMPANIES = [ + "Anduril", "Arm Holdings", "Cohere", "CrowdStrike", "Klaviyo", + "Mistral AI", "Palantir", "Perplexity", "Reddit", "xAI", +] diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/evaluators.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/evaluators.py new file mode 100644 index 0000000..506aebb --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/scripts/memo/evaluators.py @@ -0,0 +1,61 @@ +import re +from urllib.parse import urlparse +from langsmith.evaluation import EvaluationResult, run_evaluator + +URL_RE = re.compile(r"https?://\S+") + +PRIMARY_HOST_RE = re.compile( + r"(^|\.)(investors?|ir|investorrelations?|press|news|newsroom|press-?releases?|media)\.", + re.IGNORECASE, +) +PRIMARY_DOMAINS = {"sec.gov", "edgar.sec.gov", "businesswire.com", + "prnewswire.com", "globenewswire.com"} +AGGREGATOR_DOMAINS = {"en.wikipedia.org", "crunchbase.com", "pitchbook.com", + "simplywall.st", "stockanalysis.com", "finance.yahoo.com", + "reddit.com", "medium.com", "macrotrends.net"} + + +def _classify(url: str, company: str) -> str: + """Classify a cited URL as primary, aggregator, or neutral source.""" + host = urlparse(url).netloc.lower() + if host in PRIMARY_DOMAINS or PRIMARY_HOST_RE.search(host): + return "primary" + co_words = [w.lower() for w in company.split() if len(w) > 3] + if any(w in host for w in co_words): + return "primary" # Company's own domain counts as primary + if host in AGGREGATOR_DOMAINS: + return "aggregator" + return "neutral" + + +@run_evaluator +def primary_source_rate(run, example) -> EvaluationResult: + """Share of citations from primary sources (IR pages, SEC, official press) + rather than aggregators (Wikipedia, Crunchbase). Neutral domains are + excluded from the ratio.""" + memo = run.outputs["memo_md"] + company = example.inputs["company"] + urls = [u.rstrip(".,)") for u in URL_RE.findall(memo)] + primary = sum(1 for u in urls if _classify(u, company) == "primary") + aggregator = sum(1 for u in urls if _classify(u, company) == "aggregator") + denom = primary + aggregator + score = primary / denom if denom else None + return EvaluationResult(key="primary_source_rate", score=score) + + +@run_evaluator +def financial_concept_coverage(run, example) -> EvaluationResult: + """Of four financial concepts (valuation, revenue/ARR, funding, operating + metrics), how many appear in the Financials section?""" + memo = run.outputs["memo_md"] + m = re.search(r"##.*Financials.*?\n(.*?)(?=\n##\s|\Z)", memo, re.DOTALL | re.IGNORECASE) + if not m: + return EvaluationResult(key="financial_concept_coverage", score=0.0) + body = m.group(1).lower() + hits = sum([ + bool(re.search(r"valuation|valued at|market cap|post-money|pre-money", body)), + bool(re.search(r"revenue|arr |annual recurring|run.?rate", body)), + bool(re.search(r"raised|series [a-h]|funding round|total funding", body)), + bool(re.search(r"gross margin|operating margin|cash position|growth|customers|headcount|employees", body)), + ]) + return EvaluationResult(key="financial_concept_coverage", score=hits / 4) diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/graph.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/graph.py new file mode 100644 index 0000000..7233783 --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/scripts/memo/graph.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Annotated, Any, TypedDict + +from langchain_core.messages import AIMessage +from langchain_perplexity import ChatPerplexity +from langgraph.graph import END, START, StateGraph + + +def merge_research_output(left: dict[str, str], right: dict[str, str]) -> dict[str, str]: + """Each research node returns {"
": "..."}; merge into one dict.""" + return {**(left or {}), **(right or {})} + + +class MemoState(TypedDict): + company: str + research_output: Annotated[dict[str, str], merge_research_output] + memo: str + + +SUBNODE_MODEL_NAME = "openai/gpt-5.5" +SYNTHESIZER_MODEL_NAME = "openai/gpt-5.5" + + +def _agent_model(model: str) -> ChatPerplexity: + """Build a ChatPerplexity client wired to the Responses API.""" + # The Responses (Agent) API ignores sampling params like temperature, so we omit it. + return ChatPerplexity(model=model, use_responses_api=True) + + +SUBNODE_MODEL = _agent_model(SUBNODE_MODEL_NAME) +SYNTHESIZER_MODEL = _agent_model(SYNTHESIZER_MODEL_NAME) + + +# Per-research-node tool specs. +TEAM_TOOLS = [{ + "type": "web_search", + "filters": {"search_recency_filter": "year"}, +}] + +PRODUCT_TOOLS = [{"type": "web_search"}] + +MARKET_TOOLS = [{"type": "web_search"}] + +FINANCIALS_TOOLS = [{"type": "finance_search"}, {"type": "web_search"}] + + +# Per-research-node max_steps caps the Perplexity Agent API's internal search loop. +RESEARCH_MAX_STEPS = { + "team": 2, "financials": 5, "product": 2, "market": 2, +} + + +RESEARCH_PROMPT = """You are a VC analyst writing the {section} section of the research output for {company}. + +{guidance} + +Return a markdown section, then end the document with a "### Citations" header \ +followed by a markdown list of: + + - — one-sentence evidence quoted from the source + +Cite only URLs that came back from your tool calls; never fabricate URLs. \ +Keep the section focused — 250-400 words is appropriate for the body.""" + + +GUIDANCE = { + "team": ( + "Search for the founders, CEO, and other named executives. Capture each " + "leader's prior roles and education. Prioritize the company's own About/Team " + "page and professional-network sources." + ), + "financials": ( + "If the company is public, use finance_search for revenue, margins, and analyst " + "estimates. If private, use web_search for funding rounds, valuation, and " + "disclosed revenue. Cross-check structured data against recent news." + ), + "product": ( + "Describe the company's flagship product, recent launches, and technical " + "differentiators. Cite the company's own product or engineering pages where " + "possible, plus tech-press coverage for context." + ), + "market": ( + "Map the competitive landscape, name direct competitors, and surface market " + "sizing. Your web_search is scoped to analyst and trade-press sources." + ), +} + + +def _run_research( + state: MemoState, + *, + section: str, + tools: list[dict[str, Any]], + max_steps: int, +) -> dict[str, dict[str, str]]: + """Run one research section with the given tools and return its output.""" + msg: AIMessage = SUBNODE_MODEL.invoke( + [ + {"role": "system", "content": RESEARCH_PROMPT.format( + section=section, company=state["company"], guidance=GUIDANCE[section], + )}, + {"role": "user", "content": f"Research the {section} of {state['company']}."}, + ], + tools=tools, + extra_body={"max_steps": max_steps}, + ) + return {"research_output": {section: msg.content}} + + +def team_node(state): + """Research the founders and leadership team.""" + return _run_research(state, section="team", + tools=TEAM_TOOLS, max_steps=RESEARCH_MAX_STEPS["team"]) + +def financials_node(state): + """Research revenue, funding, and financial metrics.""" + return _run_research(state, section="financials", + tools=FINANCIALS_TOOLS, max_steps=RESEARCH_MAX_STEPS["financials"]) + +def product_node(state): + """Research the product, launches, and technical differentiators.""" + return _run_research(state, section="product", + tools=PRODUCT_TOOLS, max_steps=RESEARCH_MAX_STEPS["product"]) + +def market_node(state): + """Research the competitive landscape and market sizing.""" + return _run_research(state, section="market", + tools=MARKET_TOOLS, max_steps=RESEARCH_MAX_STEPS["market"]) + + +SYNTH_PROMPT = """You are a senior VC partner writing the final memo for {company}. + +You may only cite evidence that appears in the research outputs below. You have no \ +tools; do not browse or fabricate sources. + +Produce a markdown memo with these seven sections, in order: + + 1. Snapshot — what the company is, founded, valuation, positioning (3-4 sentences) + 2. Team — founders, leadership, recent senior hires + 3. Financials — revenue, growth, funding history, comparables + 4. Product — what they sell, technology, distribution + 5. Market — TAM, direct competitors, category dynamics + 6. Risks — top 3-5 risks with brief reasoning + 7. Thesis — 1-2 paragraphs of analysis, ending with a single line: + "Recommendation: " + +Each section's H2 heading must be exactly `## ·
` \ +(e.g. `## 1 · Snapshot`), using a middle-dot separator — the evaluator depends \ +on this format. + +Each of sections 1-6 must end with a `### Citations` subsection listing the \ + pairs drawn from the research outputs. Section 7 (Thesis) does \ +not need its own citations. + +If a research output lacks evidence for a section, write "Insufficient evidence in \ +research outputs." in that section's body instead of guessing.""" + + +def synthesizer_node(state: MemoState) -> dict[str, str]: + """Combine all research outputs into the final memo. No tools attached.""" + research_output_block = "\n\n".join( + f"## Research output: {name}\n\n{body}" + for name, body in sorted(state["research_output"].items()) + ) + msg: AIMessage = SYNTHESIZER_MODEL.invoke([ + {"role": "system", "content": SYNTH_PROMPT.format(company=state["company"])}, + {"role": "user", "content": ( + f"Company: {state['company']}\n" + f"As-of: {datetime.now(timezone.utc).isoformat(timespec='seconds')}\n\n" + f"Research outputs:\n\n{research_output_block}" + )}, + ]) + return {"memo": msg.content} + + +def build_graph(): + """Wire the four research nodes in parallel from START into the synthesizer, then END.""" + g = StateGraph(MemoState) + g.add_node("team", team_node) + g.add_node("financials", financials_node) + g.add_node("product", product_node) + g.add_node("market", market_node) + g.add_node("synthesizer", synthesizer_node) + + for section in ("team", "financials", "product", "market"): + g.add_edge(START, section) + g.add_edge(section, "synthesizer") + + g.add_edge("synthesizer", END) + return g.compile() diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/main.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/main.py new file mode 100644 index 0000000..ac76205 --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/scripts/memo/main.py @@ -0,0 +1,23 @@ +import argparse +import asyncio + +from .graph import build_graph + + +async def run_memo(company: str) -> str: + """Run the full memo agent for one company and return the final markdown memo.""" + graph = build_graph() + final = await graph.ainvoke({"company": company, "research_output": {}, "memo": ""}) + return final["memo"] + + +def main() -> None: + """CLI entrypoint: parse `--company` and print the generated memo.""" + parser = argparse.ArgumentParser(description="VC investment memo agent.") + parser.add_argument("--company", required=True) + args = parser.parse_args() + print(asyncio.run(run_memo(args.company))) + + +if __name__ == "__main__": + main() diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/profiles.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/profiles.py new file mode 100644 index 0000000..d98504c --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/scripts/memo/profiles.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from langchain_core.utils.function_calling import convert_to_openai_tool +from langchain_exa import ExaSearchResults +from langchain_parallel import ParallelSearchTool +from langchain_perplexity import ChatPerplexity, PerplexitySearchResults +from langgraph.graph import END, START, StateGraph + +from . import _compat # noqa: F401 temporary tool-loop shim; see _compat docstring +from .graph import ( + GUIDANCE, RESEARCH_PROMPT, MemoState, SYNTH_PROMPT, +) + + +# Rich tool descriptions teach the LLM to use date filters and multi-query +# variants when calling each provider — small change, big impact on coverage. +TOOL_DESCRIPTIONS = { + "perplexity_search_results_json": ( + "Perplexity Search API: ranked web results with title, URL, snippet, and date. " + "Pass `query` as a list of 2-3 diverse phrasings and set " + "`search_recency_filter=\"year\"` for time-sensitive lookups." + ), + "parallel_web_search": ( + "Parallel Search API: pass `objective` as a one-sentence research goal plus " + "`search_queries` as 2-3 short keyword variants (3-6 words each)." + ), + "exa_search_results_json": ( + "Exa Search API: pass `query` as a natural-language string and set " + "`start_published_date` to ~12 months ago for time-sensitive lookups." + ), +} + + +@dataclass +class ProviderProfile: + name: str + build_graph: Callable[[], Any] + + +def _to_flat_function_tool(tool): + """Convert a LangChain tool to a flat OpenAI function-tool spec for the Responses API.""" + nested = convert_to_openai_tool(tool) + fn = nested["function"] + desc = TOOL_DESCRIPTIONS.get(fn["name"], fn.get("description", "")) + return {"type": "function", "name": fn["name"], "description": desc, + "parameters": fn.get("parameters", {})} + + +def _research_node(section, sub_model, search_tool): + """Build a research node closure that loops tool calls until the model returns a final answer.""" + bound = sub_model.bind(tools=[_to_flat_function_tool(search_tool)]) + + def _research(state: MemoState) -> dict: + """Run the tool-calling loop for this section and return its research output.""" + history = [ + {"role": "system", "content": RESEARCH_PROMPT.format( + section=section, company=state["company"], guidance=GUIDANCE[section], + )}, + {"role": "user", "content": f"Research the {section} of {state['company']}."}, + ] + for _ in range(8): + msg = bound.invoke(history) + if not getattr(msg, "tool_calls", None): + return {"research_output": {section: msg.content}} + history.append(msg) + for tc in msg.tool_calls: + result = search_tool.invoke(tc["args"]) + history.append({"role": "tool", "tool_call_id": tc["id"], "content": str(result)}) + return {"research_output": {section: msg.content}} + + return _research + + +def _build_graph_for(sub_model, synth_model, search_tool): + """Build a StateGraph wired with the given models and a single search tool for all research nodes.""" + def _synth(state: MemoState) -> dict: + """Synthesize the final memo from all research outputs.""" + research_output_block = "\n\n".join( + f"## Research output: {name}\n\n{body}" + for name, body in sorted(state["research_output"].items()) + ) + msg = synth_model.invoke([ + {"role": "system", "content": SYNTH_PROMPT.format(company=state["company"])}, + {"role": "user", "content": f"Company: {state['company']}\n\nResearch outputs:\n\n{research_output_block}"}, + ]) + return {"memo": msg.content} + + g = StateGraph(MemoState) + g.add_node("synthesizer", _synth) + for section in ("team", "financials", "product", "market"): + g.add_node(section, _research_node(section, sub_model, search_tool)) + g.add_edge(START, section) + g.add_edge(section, "synthesizer") + g.add_edge("synthesizer", END) + return g.compile() + + +def _model() -> ChatPerplexity: + """Build the shared ChatPerplexity client used by every provider profile.""" + return ChatPerplexity(model="openai/gpt-5.5", use_responses_api=True) + + +def _build(search_tool): + """Shortcut: build a graph using the default model for both subnodes and synthesizer.""" + return _build_graph_for(sub_model=_model(), synth_model=_model(), search_tool=search_tool) + + +PROFILES = { + "perplexity": ProviderProfile("perplexity", lambda: _build(PerplexitySearchResults(max_results=8))), + "parallel": ProviderProfile("parallel", lambda: _build(ParallelSearchTool(max_results=8))), + "exa": ProviderProfile("exa", lambda: _build(ExaSearchResults(num_results=8))), +} diff --git a/docs/articles/langchain-vc-memo-agent/scripts/requirements.txt b/docs/articles/langchain-vc-memo-agent/scripts/requirements.txt new file mode 100644 index 0000000..40caa82 --- /dev/null +++ b/docs/articles/langchain-vc-memo-agent/scripts/requirements.txt @@ -0,0 +1,9 @@ +langchain-perplexity>=1.3.1 # use_responses_api landed in langchain-ai/langchain#37359 +langgraph>=0.2.50 +langchain-core>=0.3 +langsmith>=0.2 +httpx>=0.27 + +# Section 2 only +langchain-parallel>=0.4 +langchain-exa>=0.2 diff --git a/static/img/langchain-vc-memo-architecture.png b/static/img/langchain-vc-memo-architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..079b851441bb33410cb1ec8b85bdb80426a0c132 GIT binary patch literal 18665 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⩔P0bR1 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 DHNmSv literal 0 HcmV?d00001 From f28f111651fc7dcf51bdea164c32fd3a6bf6d10f Mon Sep 17 00:00:00 2001 From: Ryan Buchmayer Date: Wed, 10 Jun 2026 09:03:58 -0700 Subject: [PATCH 2/4] docs(articles): use bind_tools from langchain-perplexity 1.4.0, drop tool-loop shim The client-side tool round-trip shipped upstream in langchain-ai/langchain#37934 (langchain-perplexity 1.4.0), so the _compat.py shim is no longer needed. Section 2 now binds search tools with the standard bind_tools API, and the Perplexity tool gets an explicit parameter schema so model tool calls stay well-formed. --- .../langchain-vc-memo-agent/README.mdx | 4 +- .../scripts/memo/_compat.py | 72 ------------------- .../scripts/memo/profiles.py | 40 +++++++---- .../scripts/requirements.txt | 2 +- 4 files changed, 30 insertions(+), 88 deletions(-) delete mode 100644 docs/articles/langchain-vc-memo-agent/scripts/memo/_compat.py diff --git a/docs/articles/langchain-vc-memo-agent/README.mdx b/docs/articles/langchain-vc-memo-agent/README.mdx index a0cc5a0..f78949e 100644 --- a/docs/articles/langchain-vc-memo-agent/README.mdx +++ b/docs/articles/langchain-vc-memo-agent/README.mdx @@ -157,8 +157,7 @@ scripts/ ├── profiles.py # section 2: the three swappable provider profiles ├── evaluators.py # LangSmith evaluators ├── eval_dataset.py # companies used as eval inputs - ├── compare.py # runs the LangSmith comparison - └── _compat.py # temporary shim (see Limitations) + └── compare.py # runs the LangSmith comparison ``` ## Links @@ -171,6 +170,5 @@ scripts/ ## Limitations -- **Client-side tool loop shim.** Section 2 drives a client-side function-tool loop to compare external search providers. The released `langchain-perplexity` (≤ 1.3.1) does not serialize `ToolMessage` / `AIMessage.tool_calls` back to the API, so `scripts/memo/_compat.py` patches the two converters. Remove it once the upstream fix ships. The core agent (Section 1) uses Perplexity's built-in server-side tools and needs no shim. - **Section 2 uses web search only.** The provider comparison swaps web-search backends, so the Perplexity profile there does not use `finance_search` — financial-concept coverage for private companies can vary run to run. - **The seven-section template and PASS / TRACK / ADVANCE / LEAD scale are conventions defined for this agent** — swap in your own firm's format. diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/_compat.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/_compat.py deleted file mode 100644 index bd1e883..0000000 --- a/docs/articles/langchain-vc-memo-agent/scripts/memo/_compat.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Temporary shim: client-side tool-calling round-trip for ChatPerplexity. - -Section 2 drives a client-side function-tool loop (bind a search tool, let the -model emit tool_calls, feed results back as ToolMessages). The released -``langchain-perplexity`` (<= 1.3.1, i.e. the merged langchain-ai/langchain#37359) -cannot serialize that loop back to the API: - - * ``_convert_message_to_dict`` has no ``ToolMessage`` branch (raises - ``TypeError: Got unknown type``) and its ``AIMessage`` branch silently drops - ``tool_calls``; - * ``_to_responses_payload`` forwards Chat-Completions-style dicts unchanged, but - the Responses (Agent) API needs typed ``function_call`` / ``function_call_output`` - input items. - -``langchain-openai`` already handles all of this; the Perplexity package was -modeled on it but never inherited the branches. Importing this module monkey-patches -both methods so the loop round-trips. Remove it once the upstream fix ships. -""" -from __future__ import annotations - -import json - -import langchain_perplexity.chat_models as _cm -from langchain_core.messages import AIMessage, ToolMessage - -_orig_convert = _cm.ChatPerplexity._convert_message_to_dict -_orig_responses = _cm.ChatPerplexity._to_responses_payload - - -def _convert_message_to_dict(self, message): - """Add the ToolMessage / AIMessage.tool_calls branches (mirrors langchain-openai).""" - if isinstance(message, ToolMessage): - return {"role": "tool", "content": message.content, - "tool_call_id": message.tool_call_id} - if isinstance(message, AIMessage): - out = {"role": "assistant", "content": message.content or ""} - if message.tool_calls: - out["tool_calls"] = [ - {"id": tc["id"], "type": "function", - "function": {"name": tc["name"], "arguments": json.dumps(tc["args"])}} - for tc in message.tool_calls - ] - return out - return _orig_convert(self, message) - - -def _to_responses_payload(self, message_dicts, params, *, user_set_keys=None): - """Translate Chat-Completions tool turns into Responses-API typed input items.""" - payload = _orig_responses(self, message_dicts, params, user_set_keys=user_set_keys) - translated = [] - for m in payload.get("input", []): - if not isinstance(m, dict): - translated.append(m) - elif m.get("role") == "assistant" and m.get("tool_calls"): - if m.get("content"): - translated.append({"role": "assistant", "content": m["content"]}) - for tc in m["tool_calls"]: - translated.append({"type": "function_call", "call_id": tc["id"], - "name": tc["function"]["name"], - "arguments": tc["function"]["arguments"]}) - elif m.get("role") == "tool": - out = m["content"] if isinstance(m["content"], str) else json.dumps(m["content"]) - translated.append({"type": "function_call_output", - "call_id": m["tool_call_id"], "output": out}) - else: - translated.append(m) - payload["input"] = translated - return payload - - -_cm.ChatPerplexity._convert_message_to_dict = _convert_message_to_dict -_cm.ChatPerplexity._to_responses_payload = _to_responses_payload diff --git a/docs/articles/langchain-vc-memo-agent/scripts/memo/profiles.py b/docs/articles/langchain-vc-memo-agent/scripts/memo/profiles.py index d98504c..9780851 100644 --- a/docs/articles/langchain-vc-memo-agent/scripts/memo/profiles.py +++ b/docs/articles/langchain-vc-memo-agent/scripts/memo/profiles.py @@ -9,18 +9,17 @@ from langchain_perplexity import ChatPerplexity, PerplexitySearchResults from langgraph.graph import END, START, StateGraph -from . import _compat # noqa: F401 temporary tool-loop shim; see _compat docstring from .graph import ( GUIDANCE, RESEARCH_PROMPT, MemoState, SYNTH_PROMPT, ) -# Rich tool descriptions teach the LLM to use date filters and multi-query -# variants when calling each provider — small change, big impact on coverage. +# Rich tool descriptions teach the LLM how to call each provider well -- +# small change, big impact on coverage. TOOL_DESCRIPTIONS = { "perplexity_search_results_json": ( "Perplexity Search API: ranked web results with title, URL, snippet, and date. " - "Pass `query` as a list of 2-3 diverse phrasings and set " + "Pass `query` as a concise search string and set " "`search_recency_filter=\"year\"` for time-sensitive lookups." ), "parallel_web_search": ( @@ -40,18 +39,35 @@ class ProviderProfile: build_graph: Callable[[], Any] -def _to_flat_function_tool(tool): - """Convert a LangChain tool to a flat OpenAI function-tool spec for the Responses API.""" - nested = convert_to_openai_tool(tool) - fn = nested["function"] - desc = TOOL_DESCRIPTIONS.get(fn["name"], fn.get("description", "")) - return {"type": "function", "name": fn["name"], "description": desc, - "parameters": fn.get("parameters", {})} +# Explicit parameter schemas keep each tool's surface small: the model only +# sees the filters this example actually uses, so its calls stay well-formed. +TOOL_PARAMETERS = { + "perplexity_search_results_json": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "The search query."}, + "max_results": {"type": "integer", "description": "Maximum results to return."}, + "search_recency_filter": {"type": "string", "enum": ["day", "week", "month", "year"], + "description": "Restrict to a recent window."}, + }, + "required": ["query"], + }, +} + + +def _to_function_tool(tool): + """Convert a LangChain tool to an OpenAI function-tool spec, applying the overrides above.""" + spec = convert_to_openai_tool(tool) + fn = spec["function"] + fn["description"] = TOOL_DESCRIPTIONS.get(fn["name"], fn.get("description", "")) + if fn["name"] in TOOL_PARAMETERS: + fn["parameters"] = TOOL_PARAMETERS[fn["name"]] + return spec def _research_node(section, sub_model, search_tool): """Build a research node closure that loops tool calls until the model returns a final answer.""" - bound = sub_model.bind(tools=[_to_flat_function_tool(search_tool)]) + bound = sub_model.bind_tools([_to_function_tool(search_tool)]) def _research(state: MemoState) -> dict: """Run the tool-calling loop for this section and return its research output.""" diff --git a/docs/articles/langchain-vc-memo-agent/scripts/requirements.txt b/docs/articles/langchain-vc-memo-agent/scripts/requirements.txt index 40caa82..a49033a 100644 --- a/docs/articles/langchain-vc-memo-agent/scripts/requirements.txt +++ b/docs/articles/langchain-vc-memo-agent/scripts/requirements.txt @@ -1,4 +1,4 @@ -langchain-perplexity>=1.3.1 # use_responses_api landed in langchain-ai/langchain#37359 +langchain-perplexity>=1.4.0 # bind_tools + Responses-API tool round-trip landed in langchain-ai/langchain#37934 langgraph>=0.2.50 langchain-core>=0.3 langsmith>=0.2 From fd133f55b39c7cfffe6f85ae9352e68a2bda8093 Mon Sep 17 00:00:00 2001 From: Ryan Buchmayer Date: Wed, 10 Jun 2026 13:03:19 -0700 Subject: [PATCH 3/4] docs(articles): align README with shipped code and cookbook style - build_graph() snippet now matches scripts/memo/graph.py - synthesizer section states the 7-sections-from-4-nodes citation model (sections 1-6 cite, Thesis is analysis-only) - results paragraph states the run's numbers and invites re-scoring - rename Code explanation -> Directory structure, trim keywords --- .../articles/langchain-vc-memo-agent/README.mdx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/articles/langchain-vc-memo-agent/README.mdx b/docs/articles/langchain-vc-memo-agent/README.mdx index f78949e..68f39ef 100644 --- a/docs/articles/langchain-vc-memo-agent/README.mdx +++ b/docs/articles/langchain-vc-memo-agent/README.mdx @@ -2,7 +2,7 @@ title: VC Investment Memo Agent with LangGraph description: Build an auditable, citation-grounded VC research agent with LangGraph and the Perplexity Agent API, and pick the best search provider with a LangSmith eval harness. sidebar_position: 3 -keywords: [langgraph, langchain, langsmith, agent api, web_search, finance_search, investment memo, multi-agent, citations, evaluation] +keywords: [langgraph, langchain, langsmith, agent api, investment memo, evaluation] --- # Building an Auditable VC Investment Memo Agent with LangGraph and the Perplexity Agent API @@ -79,7 +79,7 @@ PRODUCT_TOOLS = MARKET_TOOLS = [{"type": "web_search"}] ### The synthesizer -The synthesizer has no tools. It cites only from the research outputs, so anything in the memo is grounded in research the nodes actually did. +The synthesizer has no tools. It composes all seven memo sections from the four nodes' research outputs, so every cited claim is grounded in research one of the nodes actually did. Sections 1–6 each end with a `### Citations` list pairing every source URL with the evidence it supports; the Thesis is the one analysis-only section, with no citations. ```python def synthesizer_node(state: MemoState) -> dict[str, str]: @@ -102,11 +102,16 @@ Four research nodes fan out from `START` in parallel and converge on the synthes ```python def build_graph(): g = StateGraph(MemoState) + g.add_node("team", team_node) + g.add_node("financials", financials_node) + g.add_node("product", product_node) + g.add_node("market", market_node) + g.add_node("synthesizer", synthesizer_node) + for section in ("team", "financials", "product", "market"): - g.add_node(section, research_node(section)) g.add_edge(START, section) # fan out from START, in parallel g.add_edge(section, "synthesizer") # ...and back into the synthesizer - g.add_node("synthesizer", synthesizer_node) + g.add_edge("synthesizer", END) return g.compile() ``` @@ -143,9 +148,9 @@ Scored across ten public and private companies on `openai/gpt-5.5`: | Latency p50 (s/memo) | **91** | 192 | 143 | | Cost (USD/memo) | **$0.38** | $0.60 | $0.67 | -Perplexity posted a perfect primary-source rate, the fastest memos, and the lowest cost per run, while tying for the best financial-concept coverage. For an output that has to withstand scrutiny — a VC memo does — that mix of source quality and speed is hard to beat. +Perplexity posted a perfect primary-source rate, the fastest memos, the lowest cost per run, and tied for the best financial-concept coverage on this run. Re-score the providers on your own dataset to see how they compare for your use case. -## Code explanation +## Directory structure ``` scripts/ From 0f97f0c4d5526c4455a151dfc9ce905913646527 Mon Sep 17 00:00:00 2001 From: Ryan Buchmayer Date: Wed, 10 Jun 2026 13:09:13 -0700 Subject: [PATCH 4/4] docs(articles): match the blog's voice; brief and focused - no em dashes; feature/limitation bullets use the blog's bolded-lead style - synthesizer, wiring, and limitations wording now identical to the blog - remove undefined 'Section 2' references (notebook-speak); say 'provider comparison' - add the blog's source-availability limitation for parity --- .../langchain-vc-memo-agent/README.mdx | 33 ++++++++++--------- .../scripts/.env.example | 2 +- .../scripts/requirements.txt | 2 +- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/docs/articles/langchain-vc-memo-agent/README.mdx b/docs/articles/langchain-vc-memo-agent/README.mdx index 68f39ef..77e028a 100644 --- a/docs/articles/langchain-vc-memo-agent/README.mdx +++ b/docs/articles/langchain-vc-memo-agent/README.mdx @@ -9,28 +9,28 @@ keywords: [langgraph, langchain, langsmith, agent api, investment memo, evaluati ## Overview -This guide builds an agent that takes a company name and returns a seven-section VC investment memo — Snapshot, Team, Financials, Product, Market, Risks, and a Thesis ending in a one-line recommendation — where **every claim is traced back to a primary source**. +This guide builds an agent that takes a company name and returns a citation-grounded VC investment memo with seven sections: Snapshot, Team, Financials, Product, Market, Risks, and a Thesis ending in a one-line recommendation. **Every claim is traced back to a primary source.** It runs on the [Perplexity Agent API](https://docs.perplexity.ai/docs/agent-api/tools) and its built-in `web_search` and `finance_search` tools, orchestrated with [LangGraph](https://langchain-ai.github.io/langgraph/), and evaluated in [LangSmith](https://docs.langchain.com/langsmith/home). The whole build runs in about ninety seconds for roughly $0.40 per memo. -The design lesson generalizes well beyond finance: **splitting search from synthesis is a cheap, structural reliability fix for a research agent.** Four research nodes fan out in parallel, each calling the Agent API with its own tools; a final synthesizer node has *no tools* and can only cite evidence the research nodes already gathered — so the memo cannot invent a source. +The design lesson generalizes beyond finance: **separating search from synthesis is a structural reliability fix for a research agent.** Four research nodes fan out in parallel, each calling the Agent API with its own tools. A final synthesizer node has no tools and can only cite evidence the research nodes already gathered, so the memo cannot invent a source. ![Company name flows into START, fans out to four parallel research nodes (team, financials, product, market), which all feed a single synthesizer node that produces the memo at END.](../../static/img/langchain-vc-memo-architecture.png) ## Features -- **Parallel research fan-out** — four focused research nodes (team, financials, product, market) run concurrently, each with its own tools and search budget. -- **Tool-less synthesizer** — the final memo is composed only from upstream evidence, a structural guard against fabricated citations. -- **Built-in Agent API tools** — `web_search` and `finance_search` work out of the box; no client-side search plumbing for the core agent. -- **Auditable in LangSmith** — every node's tool calls and outputs are captured, so any claim traces back to the search result that produced it. -- **Provider eval harness** — a LangSmith comparison that scores search providers on primary-source rate, financial-concept coverage, latency, and cost. +- **Parallel research fan-out.** Four focused research nodes (team, financials, product, market) run concurrently, each with its own tools and search budget. +- **Tool-less synthesizer.** The final memo is composed only from upstream evidence, a structural guard against fabricated citations. +- **Built-in Agent API tools.** `web_search` and `finance_search` work out of the box; no client-side search plumbing for the core agent. +- **Auditable in LangSmith.** Every node's tool calls and outputs are captured, so any claim traces back to the search result that produced it. +- **Provider eval harness.** A LangSmith comparison that scores search providers on primary-source rate, financial-concept coverage, latency, and cost. ## Prerequisites - Python 3.10+ - A [Perplexity API key](https://docs.perplexity.ai/docs/admin/api-key-management) (`PPLX_API_KEY`) - A [LangSmith API key](https://docs.smith.langchain.com/) for tracing and evaluation -- (Section 2 only) [Parallel](https://platform.parallel.ai/) and [Exa](https://dashboard.exa.ai/) API keys +- (provider comparison only) [Parallel](https://platform.parallel.ai/) and [Exa](https://dashboard.exa.ai/) API keys ## Installation @@ -79,7 +79,7 @@ PRODUCT_TOOLS = MARKET_TOOLS = [{"type": "web_search"}] ### The synthesizer -The synthesizer has no tools. It composes all seven memo sections from the four nodes' research outputs, so every cited claim is grounded in research one of the nodes actually did. Sections 1–6 each end with a `### Citations` list pairing every source URL with the evidence it supports; the Thesis is the one analysis-only section, with no citations. +The synthesizer has no tools. It composes all seven memo sections from the four nodes' research outputs, so every cited claim is grounded in research one of the nodes actually did. Sections 1–6 each end with a `### Citations` list pairing every source URL with the evidence it supports. The Thesis is the one analysis-only section, with no citations. ```python def synthesizer_node(state: MemoState) -> dict[str, str]: @@ -97,7 +97,7 @@ def synthesizer_node(state: MemoState) -> dict[str, str]: ### Wiring the graph -Four research nodes fan out from `START` in parallel and converge on the synthesizer — a few lines of graph wiring. +Four research nodes fan out from `START` in parallel and converge on the synthesizer. The wiring is short: ```python def build_graph(): @@ -126,12 +126,12 @@ The full agent lives in `scripts/memo/` (`graph.py`, `main.py`). ## Choosing a search provider -A common question when building a research agent is which search provider to use. `scripts/memo/profiles.py` runs the same graph with three swappable client-side search tools — `PerplexitySearchResults`, `ParallelSearchTool`, `ExaSearchResults` — so the same metrics apply to each, and `scripts/memo/compare.py` scores them in LangSmith. +Which search provider should back the agent? `scripts/memo/profiles.py` runs the same graph with three swappable client-side search tools (`PerplexitySearchResults`, `ParallelSearchTool`, `ExaSearchResults`), and `scripts/memo/compare.py` scores them in LangSmith so the same metrics apply to each. Two custom evaluators score memo quality, alongside LangSmith's built-in latency and cost: -- `primary_source_rate` — share of citations from primary sources (IR pages, SEC, official press) rather than aggregators. -- `financial_concept_coverage` — whether the Financials section covers valuation, revenue, funding, and operating metrics. +- `primary_source_rate`: share of citations from primary sources (IR pages, SEC, official press) rather than aggregators. +- `financial_concept_coverage`: whether the Financials section covers valuation, revenue, funding, and operating metrics. ```bash python -m memo.compare @@ -159,7 +159,7 @@ scripts/ └── memo/ ├── graph.py # typed state, parallel research nodes, tool-less synthesizer, build_graph() ├── main.py # CLI entrypoint (python -m memo --company "...") - ├── profiles.py # section 2: the three swappable provider profiles + ├── profiles.py # the three swappable provider profiles ├── evaluators.py # LangSmith evaluators ├── eval_dataset.py # companies used as eval inputs └── compare.py # runs the LangSmith comparison @@ -175,5 +175,6 @@ scripts/ ## Limitations -- **Section 2 uses web search only.** The provider comparison swaps web-search backends, so the Perplexity profile there does not use `finance_search` — financial-concept coverage for private companies can vary run to run. -- **The seven-section template and PASS / TRACK / ADVANCE / LEAD scale are conventions defined for this agent** — swap in your own firm's format. +- **Know where it falls short.** The agent is only as strong as the primary sources it can find: solid for well-documented companies, shakier for thinly-covered private startups where little has been published. +- **The provider comparison uses web search only.** It swaps web-search backends, so the Perplexity profile there does not use `finance_search`; financial-concept coverage for private companies can vary run to run. +- **The section template is just a convention.** The seven sections and the PASS / TRACK / ADVANCE / LEAD scale are the format we picked; swap in whatever your team uses. diff --git a/docs/articles/langchain-vc-memo-agent/scripts/.env.example b/docs/articles/langchain-vc-memo-agent/scripts/.env.example index 3f0718c..b16789c 100644 --- a/docs/articles/langchain-vc-memo-agent/scripts/.env.example +++ b/docs/articles/langchain-vc-memo-agent/scripts/.env.example @@ -6,6 +6,6 @@ PPLX_API_KEY=pplx-... LANGSMITH_API_KEY=ls__... LANGSMITH_TRACING=true -# Section 2 only +# Provider comparison only PARALLEL_API_KEY=... EXA_API_KEY=... diff --git a/docs/articles/langchain-vc-memo-agent/scripts/requirements.txt b/docs/articles/langchain-vc-memo-agent/scripts/requirements.txt index a49033a..97ce10f 100644 --- a/docs/articles/langchain-vc-memo-agent/scripts/requirements.txt +++ b/docs/articles/langchain-vc-memo-agent/scripts/requirements.txt @@ -4,6 +4,6 @@ langchain-core>=0.3 langsmith>=0.2 httpx>=0.27 -# Section 2 only +# Provider comparison only langchain-parallel>=0.4 langchain-exa>=0.2