diff --git a/.env.template b/.env.template
new file mode 100644
index 0000000..4646485
--- /dev/null
+++ b/.env.template
@@ -0,0 +1,4 @@
+OPENAI_API_KEY=your_openai_api_key
+OLOSTEP_API_KEY=your_olostep_api_key
+OPENAI_MODEL=gpt-5.4-mini
+RUN_LIVE_EXAMPLE=true
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 83972fa..fa87e4a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,14 @@
+# Reflex generated files
+.web/
+.states/
+assets/external/
+frontend.zip
+backend.zip
+reflex.out.log
+reflex.err.log
+.playwright-cli/
+*.db
+
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
diff --git a/README.md b/README.md
index cc5f247..d232165 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# Multi-Agent Research Assistant
-A Jupyter notebook that builds a multi-agent research assistant with the OpenAI Agents SDK and Olostep.
+A multi-agent research assistant built with the OpenAI Agents SDK, Olostep, and Reflex.
-The workflow uses a manager agent to orchestrate a judge agent, source research agent, and analyst agent. It tries Olostep Answer API first, escalates to search-with-scrape when needed, and returns a polished Markdown research report with sources.
+Enter a research question and a team of AI agents collaborates to produce a polished, source-backed Markdown research report. The original notebook is included, and the same logic is also available as a Reflex web app.
## Flow
@@ -34,18 +34,61 @@ Manager agent
| v
+----------> Markdown research report + sources
```
+

+## Agents
+
+| Agent | Role |
+|---|---|
+| **Manager** | Orchestrates the workflow: answer, judge, research if needed, then write. |
+| **Judge** | Evaluates whether the initial answer is good enough or needs deeper research. |
+| **Source Researcher** | Gathers evidence using Olostep search, scrape, and targeted web queries. Prioritizes the most recent sources. |
+| **Analyst** | Writes the final Markdown research report from the gathered evidence. |
+
## Setup
-Create a `.env` file:
+Install dependencies:
+
+```bash
+pip install -r requirements.txt
+```
+
+Create a `.env` file from `.env.template`:
```bash
OPENAI_API_KEY=your_openai_api_key
OLOSTEP_API_KEY=your_olostep_api_key
-RUN_LIVE_EXAMPLE=false
+OPENAI_MODEL=gpt-5.4-mini
```
+## Run the Reflex app
+
+```bash
+reflex run
+```
+
+Then open the local URL printed by Reflex, usually:
+
+```text
+http://localhost:3000
+```
+
+The app files live in `app/`:
+
+- `app/app.py` — Reflex UI with styled Markdown report rendering and download.
+- `app/research_assistant.py` — OpenAI Agents SDK multi-agent workflow with Olostep tools.
+
+## Features
+
+- **Multi-agent workflow** — Manager, Judge, Source Researcher, and Analyst agents collaborate automatically.
+- **Live progress logs** — Watch each agent step in real time.
+- **Styled Markdown report** — Headings, bullets, tables, code blocks, and more render properly in the browser.
+- **Download report** — Export the full Markdown report with one click.
+- **Recent results** — The source research agent is aware of the current date and prioritizes up-to-date sources.
+
+## Run the notebook
+
Open and run:
```text
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1 @@
+
diff --git a/app/app.py b/app/app.py
new file mode 100644
index 0000000..806d23f
--- /dev/null
+++ b/app/app.py
@@ -0,0 +1,423 @@
+from __future__ import annotations
+
+import asyncio
+import time
+
+import markdown
+import reflex as rx
+
+from .research_assistant import environment_status, run_research_assistant
+
+
+PAGE_BG = "linear-gradient(135deg, #fbfdff 0%, #fffdf7 48%, #f8fff9 100%)"
+PAGE_PADDING = {"initial": "1rem", "sm": "1.25rem", "lg": "2rem"}
+PANEL_PADDING = {"initial": "1rem", "sm": "1.25rem"}
+_RUNNING_TASKS: dict[str, asyncio.Task] = {}
+
+
+def _plain_markdown(value) -> str:
+ if isinstance(value, str):
+ return value
+ if isinstance(value, dict):
+ nested = value.get("markdown_report")
+ return nested if isinstance(nested, str) else str(nested or value)
+ nested = getattr(value, "markdown_report", None)
+ return nested if isinstance(nested, str) else str(value)
+
+
+class State(rx.State):
+ query: str = ""
+ logs: list[str] = []
+ report_markdown: str = ""
+ report_html: str = ""
+ trace_url: str = ""
+ error: str = ""
+ is_running: bool = False
+ status: str = "Ready"
+ active_run_id: int = 0
+ step_started_at: float = 0.0
+
+ def set_query(self, value: str) -> None:
+ self.query = value
+
+ def _client_token(self) -> str:
+ return self.router.session.client_token or "default"
+
+ def _cancel_active_task(self) -> bool:
+ task = _RUNNING_TASKS.pop(self._client_token(), None)
+ if task is not None and not task.done():
+ task.cancel()
+ return True
+ return False
+
+ def clear_all(self) -> None:
+ self._cancel_active_task()
+ self.active_run_id += 1
+ self.query = ""
+ self.logs = []
+ self.report_markdown = ""
+ self.report_html = ""
+ self.trace_url = ""
+ self.error = ""
+ self.is_running = False
+ self.status = "Ready"
+ self.step_started_at = 0.0
+
+ def stop_report(self) -> None:
+ if not self.is_running:
+ return
+ now = time.monotonic()
+ elapsed = max(0.0, now - self.step_started_at) if self.step_started_at else 0.0
+ self._cancel_active_task()
+ self.is_running = False
+ self.error = ""
+ self.status = "Stopped"
+ self.step_started_at = now
+ self.logs.append(f"{elapsed:.1f}s Research stopped by user.")
+
+ async def _log(self, message: str) -> None:
+ async with self:
+ now = time.monotonic()
+ elapsed = max(0.0, now - self.step_started_at) if self.step_started_at else 0.0
+ self.step_started_at = now
+ self.logs.append(f"{elapsed:.1f}s {message}")
+
+ @rx.event(background=True)
+ async def run_report(self):
+ task = asyncio.current_task()
+ run_id = 0
+ async with self:
+ query = self.query.strip()
+ if not query:
+ self.error = ""
+ return
+ self.active_run_id += 1
+ run_id = self.active_run_id
+ if task is not None:
+ _RUNNING_TASKS[self._client_token()] = task
+ self.logs = []
+ self.report_markdown = ""
+ self.report_html = ""
+ self.trace_url = ""
+ self.error = ""
+ self.is_running = True
+ self.status = "Researching"
+ self.step_started_at = time.monotonic()
+
+ try:
+ report, trace_url = await run_research_assistant(query, progress=self._log)
+ async with self:
+ self.report_markdown = _plain_markdown(report.markdown_report)
+ self.report_html = markdown.markdown(
+ self.report_markdown,
+ extensions=["extra", "sane_lists", "tables"],
+ output_format="html5",
+ )
+ self.trace_url = trace_url
+ self.status = "Complete"
+ except asyncio.CancelledError:
+ async with self:
+ if self.active_run_id == run_id:
+ self.error = ""
+ self.status = "Stopped"
+ except Exception as exc:
+ async with self:
+ if self.active_run_id == run_id:
+ self.error = str(exc)
+ self.status = "Failed"
+ finally:
+ async with self:
+ if self.active_run_id == run_id:
+ self.is_running = False
+ _RUNNING_TASKS.pop(self._client_token(), None)
+
+ def download_markdown(self):
+ if not self.report_markdown:
+ return rx.window_alert("Generate a report before downloading.")
+ return rx.download(data=self.report_markdown, filename="research-report.md")
+
+
+def status_badge() -> rx.Component:
+ return rx.badge(
+ State.status,
+ color_scheme=rx.cond(State.status == "Complete", "green", rx.cond(State.status == "Failed", "red", "blue")),
+ variant="soft",
+ size="2",
+ )
+
+
+def log_panel() -> rx.Component:
+ return rx.box(
+ rx.hstack(
+ rx.hstack(
+ rx.icon("activity", size=18, color="#0b7285"),
+ rx.heading("Working", size="3", color="#14323b"),
+ align="center",
+ spacing="2",
+ ),
+ status_badge(),
+ justify="between",
+ align="center",
+ width="100%",
+ ),
+ rx.vstack(
+ rx.foreach(
+ State.logs,
+ lambda item: rx.text(item, font_family="monospace", font_size="0.8rem", color="#263238"),
+ ),
+ align="stretch",
+ spacing="2",
+ margin_top="0.6rem",
+ ),
+ max_height="10rem",
+ overflow_y="auto",
+ padding="0.85rem",
+ border="1px solid #b6e3ea",
+ border_radius="8px",
+ background="rgba(235, 251, 255, 0.9)",
+ box_shadow="0 8px 24px rgba(8, 92, 115, 0.10)",
+ width="100%",
+ )
+
+
+_MARKDOWN_CSS = """
+
+"""
+
+
+def report_panel() -> rx.Component:
+ return rx.box(
+ rx.hstack(
+ rx.spacer(),
+ rx.button(
+ rx.icon("download", size=17),
+ "Download",
+ on_click=State.download_markdown,
+ background="#2f9e44",
+ color="white",
+ border_radius="8px",
+ padding_x="1rem",
+ padding_y="0.45rem",
+ cursor="pointer",
+ margin_top="1rem",
+ margin_right="1rem",
+ _hover={"background": "#238b36", "transform": "translateY(-1px)", "box_shadow": "0 4px 12px rgba(47, 158, 68, 0.35)"},
+ transition="all 0.2s ease",
+ ),
+ justify="end",
+ align="start",
+ width="100%",
+ ),
+ rx.html(
+ _MARKDOWN_CSS + '
' + State.report_html + "
",
+ width="100%",
+ overflow_x="auto",
+ ),
+ padding=PANEL_PADDING,
+ border="1px solid #dde6ec",
+ border_radius="8px",
+ background="rgba(255, 255, 255, 0.96)",
+ box_shadow="0 16px 40px rgba(36, 48, 58, 0.10)",
+ width="100%",
+ overflow_x="auto",
+ )
+
+
+def index() -> rx.Component:
+ _ok, _missing, _olostep_ver, _openai_ver = environment_status()
+ return rx.box(
+ rx.vstack(
+ rx.vstack(
+ rx.heading(
+ "What do you want to research today?",
+ size={"initial": "6", "md": "7"},
+ weight="regular",
+ color="#101828",
+ text_align="center",
+ line_height="1.15",
+ ),
+ rx.hstack(
+ rx.badge("Manager", color_scheme="blue", variant="soft", size="2", border_radius="999px"),
+ rx.badge("Judge", color_scheme="orange", variant="soft", size="2", border_radius="999px"),
+ rx.badge("Researcher", color_scheme="purple", variant="soft", size="2", border_radius="999px"),
+ rx.badge("Analyst", color_scheme="green", variant="soft", size="2", border_radius="999px"),
+ justify="center",
+ align="center",
+ wrap="wrap",
+ spacing="2",
+ ),
+ width="100%",
+ align="center",
+ spacing="3",
+ ),
+ rx.hstack(
+ rx.input(
+ value=State.query,
+ on_change=State.set_query,
+ placeholder="Ask anything",
+ height="2.8rem",
+ width="100%",
+ flex="1 1 auto",
+ min_width="0",
+ background="transparent",
+ border="0",
+ box_shadow="none",
+ font_size="1.05rem",
+ margin_left="1.5rem",
+ padding_left="0",
+ text_indent="0.85rem",
+ padding_right={"initial": "0.75rem", "sm": "1rem"},
+ ),
+ rx.button(
+ rx.icon("rotate_ccw", size=19),
+ on_click=State.clear_all,
+ aria_label="Reset",
+ height="3.15rem",
+ width="3.15rem",
+ min_width="3.15rem",
+ padding="0",
+ flex="0 0 auto",
+ border_radius="8px",
+ background="transparent",
+ color="#111111",
+ box_shadow="none",
+ cursor="pointer",
+ _hover={"background": "#f3f4f6"},
+ ),
+ rx.cond(
+ State.is_running,
+ rx.button(
+ rx.icon("square", size=16),
+ on_click=State.stop_report,
+ aria_label="Stop",
+ height="3.15rem",
+ width="3.15rem",
+ min_width="3.15rem",
+ padding="0",
+ flex="0 0 auto",
+ border_radius="999px",
+ background="#111111",
+ color="white",
+ cursor="pointer",
+ position="relative",
+ z_index="1",
+ margin_right="1rem",
+ ),
+ rx.button(
+ rx.icon("search", size=17),
+ on_click=State.run_report,
+ aria_label="Search",
+ height="3.15rem",
+ width="3.15rem",
+ min_width="3.15rem",
+ padding="0",
+ flex="0 0 auto",
+ border_radius="999px",
+ background="#111111",
+ color="white",
+ cursor="pointer",
+ position="relative",
+ z_index="1",
+ margin_right="1rem",
+ ),
+ ),
+ width="100%",
+ min_height="5rem",
+ align="center",
+ justify="between",
+ spacing={"initial": "3", "sm": "4"},
+ align_self="center",
+ padding={
+ "initial": "0.75rem 1.85rem 0.75rem 1.2rem",
+ "sm": "0.8rem 2.35rem 0.8rem 1.6rem",
+ },
+ border="1px solid #d8d8d8",
+ border_radius="999px",
+ background="rgba(255, 255, 255, 0.96)",
+ box_shadow="0 18px 55px rgba(16, 24, 40, 0.12)",
+ ),
+ rx.hstack(
+ rx.button(
+ "AI agent trends?",
+ on_click=State.set_query("AI agent trends?"),
+ variant="soft",
+ color_scheme="gray",
+ border_radius="999px",
+ ),
+ rx.button(
+ "OpenAI model updates?",
+ on_click=State.set_query("OpenAI model updates?"),
+ variant="soft",
+ color_scheme="gray",
+ border_radius="999px",
+ ),
+ rx.button(
+ "Best research tools?",
+ on_click=State.set_query("Best research tools?"),
+ variant="soft",
+ color_scheme="gray",
+ border_radius="999px",
+ ),
+ justify="center",
+ align="center",
+ wrap="wrap",
+ spacing="3",
+ width="100%",
+ ),
+ rx.cond(State.error != "", rx.callout(State.error, icon="triangle_alert", color_scheme="red", width="100%")),
+ rx.cond(
+ State.is_running,
+ log_panel(),
+ rx.cond(
+ State.report_markdown != "",
+ report_panel(),
+ rx.box(
+ rx.text("Enter a question and press Search.", color="#52616b", align="center"),
+ padding="0.25rem",
+ width="100%",
+ ),
+ ),
+ ),
+ spacing="5",
+ align="stretch",
+ padding=PANEL_PADDING,
+ width="100%",
+ max_width="780px",
+ margin_x="auto",
+ ),
+ width="100%",
+ max_width="100vw",
+ padding=PAGE_PADDING,
+ min_height="100vh",
+ background=PAGE_BG,
+ overflow_x="hidden",
+ display="flex",
+ align_items="center",
+ justify_content="center",
+ )
+
+
+app = rx.App()
+app.add_page(index, route="/", title="Multi-Agent Research Assistant")
diff --git a/app/research_assistant.py b/app/research_assistant.py
new file mode 100644
index 0000000..6d5619f
--- /dev/null
+++ b/app/research_assistant.py
@@ -0,0 +1,341 @@
+from __future__ import annotations
+
+import asyncio
+import contextvars
+import importlib.metadata
+import json
+import os
+import warnings
+from collections.abc import Awaitable, Callable
+from typing import Any
+
+from agents import Agent, Runner, custom_span, flush_traces, function_tool, gen_trace_id, trace
+from dotenv import load_dotenv
+from olostep import Olostep
+from pydantic import BaseModel, Field
+
+load_dotenv()
+
+OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
+OLOSTEP_API_KEY = os.getenv("OLOSTEP_API_KEY")
+MODEL = os.getenv("OPENAI_MODEL", "gpt-5.4-mini")
+
+warnings.filterwarnings("ignore", message=".*extra field.*SDK model.*")
+
+ProgressCallback = Callable[[str], Awaitable[None]]
+_progress_callback: contextvars.ContextVar[ProgressCallback | None] = contextvars.ContextVar(
+ "progress_callback",
+ default=None,
+)
+
+
+class OlostepError(RuntimeError):
+ """Raised when an Olostep SDK request fails."""
+
+
+class Judgment(BaseModel):
+ is_good_enough: bool = Field(description="Whether the answer is sufficient for the user query.")
+ score: float = Field(ge=0, le=1, description="Quality score from 0 to 1.")
+ reason: str = Field(description="Short explanation of the decision.")
+ missing_information: list[str] = Field(default_factory=list, description="Important gaps to fix.")
+
+
+class SourceResearchReport(BaseModel):
+ key_findings: list[str] = Field(description="Concise findings from gathered sources.")
+ important_urls: list[str] = Field(description="Only the most important URLs used for synthesis.")
+ source_notes: list[str] = Field(description="Brief notes connecting sources to findings.")
+ remaining_gaps: list[str] = Field(default_factory=list, description="Gaps that could not be resolved.")
+
+
+class MarkdownResearchReport(BaseModel):
+ title: str = Field(description="Research report title.")
+ executive_summary: str = Field(description="Short answer-first summary.")
+ key_findings: list[str] = Field(description="Most important findings.")
+ markdown_report: str = Field(
+ description="Complete Markdown report with polished headings, clear analysis, reader-friendly structure, and citations."
+ )
+ citations: list[str] = Field(default_factory=list, description="Source URLs used in the report.")
+ confidence: str = Field(description="Low, medium, or high confidence.")
+ method_used: str = Field(description="Retrieval path used by the manager agent.")
+
+
+async def emit_progress(message: str) -> None:
+ callback = _progress_callback.get()
+ if callback is not None:
+ await callback(message)
+
+
+def openai_trace_url(trace_id: str) -> str:
+ return f"https://platform.openai.com/logs/trace?trace_id={trace_id}"
+
+
+def environment_status() -> tuple[bool, list[str], str, str]:
+ missing = [
+ name
+ for name, value in {
+ "OPENAI_API_KEY": OPENAI_API_KEY,
+ "OLOSTEP_API_KEY": OLOSTEP_API_KEY,
+ }.items()
+ if not value
+ ]
+ try:
+ olostep_version = importlib.metadata.version("olostep")
+ except importlib.metadata.PackageNotFoundError:
+ olostep_version = "not installed"
+ try:
+ openai_version = importlib.metadata.version("openai-agents")
+ except importlib.metadata.PackageNotFoundError:
+ openai_version = "not installed"
+ return not missing, missing, olostep_version, openai_version
+
+
+def require_olostep_key() -> str:
+ if not OLOSTEP_API_KEY:
+ raise OlostepError("OLOSTEP_API_KEY is not set. Add it to .env and restart the app.")
+ return OLOSTEP_API_KEY
+
+
+def get_olostep_client() -> Olostep:
+ return Olostep(api_key=require_olostep_key())
+
+
+def sdk_result_to_dict(result: Any) -> dict[str, Any]:
+ if hasattr(result, "model_dump"):
+ return result.model_dump()
+ if hasattr(result, "__dict__"):
+ return {key: value for key, value in vars(result).items() if not key.startswith("_")}
+ return {"value": str(result)}
+
+
+def compact_json(data: Any, max_chars: int = 8000) -> str:
+ text = json.dumps(data, ensure_ascii=False, indent=2, default=str)
+ if len(text) <= max_chars:
+ return text
+ return text[:max_chars] + "\n... [truncated]"
+
+
+def normalize_search_links(links: list[dict[str, Any]], limit: int = 8) -> list[dict[str, Any]]:
+ rows = []
+ for link in links[:limit]:
+ markdown = link.get("markdown_content") or ""
+ rows.append(
+ {
+ "title": link.get("title") or "Untitled",
+ "url": link.get("url") or "",
+ "description": link.get("description") or "",
+ "markdown_chars": len(markdown),
+ "markdown_preview": markdown[:1500] if markdown else "",
+ }
+ )
+ return rows
+
+
+def _answer_query_impl(query: str) -> str:
+ with custom_span("olostep.answer_query", {"query": query}):
+ result = get_olostep_client().answers.create(task=query)
+ return compact_json(sdk_result_to_dict(result))
+
+
+def _search_web_impl(query: str, limit: int = 8) -> str:
+ with custom_span("olostep.search_web", {"query": query, "limit": limit}):
+ search = get_olostep_client().searches.create(query=query, limit=limit)
+ data = sdk_result_to_dict(search)
+ return compact_json(
+ {
+ "query": query,
+ "results": normalize_search_links(data.get("links", []), limit=limit),
+ "raw": data,
+ }
+ )
+
+
+def _search_with_scrape_impl(query: str, limit: int = 5) -> str:
+ scrape_options = {"formats": ["markdown"], "timeout": 25}
+ with custom_span("olostep.search_with_scrape", {"query": query, "limit": limit, "scrape_options": scrape_options}):
+ search = get_olostep_client().searches.create(
+ query=query,
+ limit=limit,
+ scrape_options=scrape_options,
+ )
+ data = sdk_result_to_dict(search)
+ return compact_json(
+ {
+ "query": query,
+ "results": normalize_search_links(data.get("links", []), limit=limit),
+ "raw": data,
+ },
+ max_chars=12000,
+ )
+
+
+def _scrape_url_impl(url: str) -> str:
+ with custom_span("olostep.scrape_url", {"url": url, "formats": ["markdown"]}):
+ scrape = get_olostep_client().scrapes.create(url=url, formats=["markdown"])
+ return compact_json({"url": url, "scrape": sdk_result_to_dict(scrape)}, max_chars=10000)
+
+
+@function_tool
+async def answer_query(query: str) -> str:
+ """Answer a natural-language research query using Olostep Answer API."""
+ await emit_progress("Calling Olostep Answer API.")
+ try:
+ result = await asyncio.to_thread(_answer_query_impl, query)
+ except Exception as exc:
+ raise OlostepError(f"Olostep Answer API failed: {exc}") from exc
+ await emit_progress("Olostep Answer API returned evidence.")
+ return result
+
+
+@function_tool
+async def search_web(query: str, limit: int = 8) -> str:
+ """Search the web using Olostep Search and return normalized results."""
+ await emit_progress(f"Searching the web with Olostep: {query}")
+ try:
+ result = await asyncio.to_thread(_search_web_impl, query, limit)
+ except Exception as exc:
+ raise OlostepError(f"Olostep Search API failed: {exc}") from exc
+ await emit_progress("Olostep Search returned results.")
+ return result
+
+
+@function_tool
+async def search_with_scrape(query: str, limit: int = 5) -> str:
+ """Search the web and scrape each returned link using Olostep Search with Scrape."""
+ await emit_progress(f"Running Olostep search with scrape: {query}")
+ try:
+ result = await asyncio.to_thread(_search_with_scrape_impl, query, limit)
+ except Exception as exc:
+ raise OlostepError(f"Olostep Search with Scrape failed: {exc}") from exc
+ await emit_progress("Search with scrape returned source content.")
+ return result
+
+
+@function_tool
+async def scrape_url(url: str) -> str:
+ """Scrape one URL with Olostep and return compact page content."""
+ await emit_progress(f"Scraping selected source: {url}")
+ try:
+ result = await asyncio.to_thread(_scrape_url_impl, url)
+ except Exception as exc:
+ raise OlostepError(f"Olostep Scrape API failed: {exc}") from exc
+ await emit_progress("Selected source scrape completed.")
+ return result
+
+
+judge_agent = Agent(
+ name="Judge agent",
+ model=MODEL,
+ instructions=(
+ "You judge whether the provided answer is good enough for the original research question. "
+ "Reward direct, specific, source-backed answers. Reject vague, stale, or unsupported answers. "
+ "Return only the structured judgment."
+ ),
+ output_type=Judgment,
+)
+
+def _build_source_research_agent():
+ from datetime import datetime
+ now = datetime.now().strftime("%B %d, %Y %I:%M %p")
+ return Agent(
+ name="Source research agent",
+ model=MODEL,
+ instructions=(
+ f"Current date and time: {now}. "
+ "You gather evidence for a research report using only the provided Olostep tools. "
+ "Always include the current year in your search queries to get the most recent results. "
+ "Prefer recent, official, primary, and reputable sources. "
+ "First try search_with_scrape for the original query. If the scraped search result is weak, "
+ "run two or three targeted search_web calls, select only the most important URLs, scrape those URLs, "
+ "and summarize the evidence. "
+ "Return only the structured source research report."
+ ),
+ tools=[search_web, search_with_scrape, scrape_url],
+ output_type=SourceResearchReport,
+ )
+
+analyst_agent = Agent(
+ name="Analyst agent",
+ model=MODEL,
+ instructions=(
+ "You write a proper Markdown research report from the evidence. "
+ "Write for a professional reader who wants a clear, polished research brief on any topic. "
+ "Adapt the report to the user's question. The markdown_report must be substantial, easy to scan, and use these general sections only: "
+ "Executive Summary, Key Findings, Context, Evidence Review, Detailed Analysis, Implications, Source Notes, and References. "
+ "If the topic is event-driven, include timeline details inside Context or Detailed Analysis instead of adding a separate Timeline section. "
+ "If the topic is comparative, include a compact comparison table inside Detailed Analysis. "
+ "Do not include sections titled Limitations, Next Steps, Recommendations, or Action Items. "
+ "Avoid bare caveats like 'I relied on...'. Instead, integrate source quality naturally in Source Notes. "
+ "Use short paragraphs, bullets where helpful, and citations as Markdown links or URL bullets. "
+ "Add enough context that a non-expert reader understands the issue, why it matters, and what evidence supports it. "
+ "Return only the structured report."
+ ),
+ output_type=MarkdownResearchReport,
+)
+
+judge_tool = judge_agent.as_tool(
+ tool_name="judge_answer_quality",
+ tool_description="Judge whether an answer or evidence is good enough for the original research question.",
+)
+
+source_research_tool = _build_source_research_agent().as_tool(
+ tool_name="run_source_research",
+ tool_description="Run Olostep search-with-scrape, targeted searches, URL selection, and URL scraping to gather source evidence.",
+)
+
+analyst_tool = analyst_agent.as_tool(
+ tool_name="write_markdown_research_report",
+ tool_description="Write the final structured Markdown research report from the gathered evidence.",
+)
+
+manager_agent = Agent(
+ name="Manager research agent",
+ model=MODEL,
+ instructions=(
+ "You are the orchestrator for a multi-agent research assistant. Follow this exact policy:\n"
+ "1. Always call answer_query first for the user's question.\n"
+ "2. Call judge_answer_quality on that Answer API result.\n"
+ "3. If the judge says the answer is good enough, call write_markdown_research_report using the answer result.\n"
+ "4. If the judge says the answer is not good enough, call run_source_research. The source researcher must use search_with_scrape first, then targeted searches and scrape_url if needed.\n"
+ "5. Call write_markdown_research_report using all evidence.\n"
+ "6. Return a complete MarkdownResearchReport. Do not return a casual chat answer."
+ ),
+ tools=[answer_query, judge_tool, source_research_tool, analyst_tool],
+ output_type=MarkdownResearchReport,
+)
+
+
+async def run_research_assistant(query: str, progress: ProgressCallback | None = None) -> tuple[MarkdownResearchReport, str]:
+ if not OPENAI_API_KEY:
+ raise RuntimeError("OPENAI_API_KEY is not set. Add it to .env and restart the app.")
+ require_olostep_key()
+
+ token = _progress_callback.set(progress)
+ trace_id = gen_trace_id()
+ trace_url = openai_trace_url(trace_id)
+
+ try:
+ await emit_progress("Starting manager research agent.")
+ prompt = f"""
+Research question:
+{query}
+
+Return a polished, reader-friendly Markdown research report with substantial detail for the user's specific question. Follow the required workflow exactly:
+- Answer API first.
+- Judge agent second.
+- If weak, source research agent with search_with_scrape, targeted search_web calls, URL selection, and scrape_url.
+- Analyst agent writes the final Markdown report. Do not include Limitations or Next Steps sections.
+"""
+ with trace(
+ workflow_name="multi_agent_research_assistant_olostep",
+ trace_id=trace_id,
+ metadata={"query": query, "app": "reflex_research_assistant"},
+ ):
+ with custom_span("manager.run", {"query": query}):
+ result = await Runner.run(manager_agent, prompt, max_turns=20)
+
+ await emit_progress("Manager run completed. Flushing OpenAI traces.")
+ flush_traces()
+ await emit_progress("Trace flushed. Rendering Markdown report.")
+ return result.final_output, trace_url
+ finally:
+ _progress_callback.reset(token)
diff --git a/multi_agent_research_assistant_openai_agents_olostep.ipynb b/multi_agent_research_assistant_openai_agents_olostep.ipynb
index cb04876..a953aae 100644
--- a/multi_agent_research_assistant_openai_agents_olostep.ipynb
+++ b/multi_agent_research_assistant_openai_agents_olostep.ipynb
@@ -743,7 +743,8 @@
},
"language_info": {
"name": "python",
- "pygments_lexer": "ipython3"
+ "pygments_lexer": "ipython3",
+ "version": "3.13.7"
}
},
"nbformat": 4,
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..479d86a
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,6 @@
+openai-agents>=0.17.1
+olostep>=1.1.0
+python-dotenv>=1.2.2
+pydantic>=2.13.4
+reflex>=0.9.2
+markdown>=3.10.2
diff --git a/rxconfig.py b/rxconfig.py
new file mode 100644
index 0000000..b6351e3
--- /dev/null
+++ b/rxconfig.py
@@ -0,0 +1,19 @@
+import reflex as rx
+
+
+config = rx.Config(
+ app_name="app",
+ env_file=".env",
+ show_built_with_reflex=False,
+ plugins=[
+ rx.plugins.SitemapPlugin(),
+ rx.plugins.RadixThemesPlugin(
+ theme=rx.theme(
+ appearance="light",
+ accent_color="blue",
+ gray_color="slate",
+ radius="medium",
+ )
+ ),
+ ],
+)