feat: add Reflex web app with styled markdown report and download

- Reflex UI with styled markdown rendering (headings, bullets, tables, code)
- Download button for markdown report export
- Source research agent now includes current date for recent results
- Reduced report width with proper padding to prevent text cutoff
- Updated README with app structure and features
This commit is contained in:
Abid Ali Awan
2026-05-12 00:48:58 +05:00
parent ba69bcab18
commit 06ce6e45cd
9 changed files with 854 additions and 5 deletions
+4
View File
@@ -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
+11
View File
@@ -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]
+47 -4
View File
@@ -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
```
![image.png](image.png)
## 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
+1
View File
@@ -0,0 +1 @@
+423
View File
@@ -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 = """
<style>
.md-report h1 { font-size: 1.65rem; font-weight: 700; margin: 1.4rem 0 0.6rem; color: #101828; border-bottom: 2px solid #e5e7eb; padding-bottom: 0.35rem; }
.md-report h2 { font-size: 1.35rem; font-weight: 600; margin: 1.2rem 0 0.5rem; color: #1e293b; border-bottom: 1px solid #f1f5f9; padding-bottom: 0.25rem; }
.md-report h3 { font-size: 1.12rem; font-weight: 600; margin: 1rem 0 0.4rem; color: #334155; }
.md-report h4 { font-size: 1rem; font-weight: 600; margin: 0.8rem 0 0.3rem; color: #475569; }
.md-report p { margin: 0.5rem 0; line-height: 1.7; color: #374151; }
.md-report ul, .md-report ol { margin: 0.5rem 0 0.5rem 1.5rem; padding: 0; }
.md-report ul { list-style-type: disc; }
.md-report ol { list-style-type: decimal; }
.md-report li { margin: 0.25rem 0; line-height: 1.65; color: #374151; }
.md-report li > ul, .md-report li > ol { margin: 0.2rem 0 0.2rem 1.2rem; }
.md-report strong { font-weight: 700; color: #111827; }
.md-report em { font-style: italic; }
.md-report blockquote { border-left: 4px solid #94a3b8; margin: 0.75rem 0; padding: 0.5rem 1rem; background: #f8fafc; color: #475569; border-radius: 0 4px 4px 0; }
.md-report code { background: #f1f5f9; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.88em; color: #7c3aed; }
.md-report pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 8px; overflow-x: auto; margin: 0.75rem 0; }
.md-report pre code { background: none; color: inherit; padding: 0; font-size: 0.88em; }
.md-report table { border-collapse: collapse; width: 100%; margin: 0.75rem 0; }
.md-report th, .md-report td { border: 1px solid #e5e7eb; padding: 0.5rem 0.75rem; text-align: left; }
.md-report th { background: #f8fafc; font-weight: 600; color: #1e293b; }
.md-report hr { border: none; border-top: 1px solid #e5e7eb; margin: 1.2rem 0; }
.md-report a { color: #2563eb; text-decoration: underline; }
</style>
"""
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 + '<div class="md-report" style="padding: 0.5rem 1.25rem 1rem;">' + State.report_html + "</div>",
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")
+341
View File
@@ -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)
@@ -743,7 +743,8 @@
},
"language_info": {
"name": "python",
"pygments_lexer": "ipython3"
"pygments_lexer": "ipython3",
"version": "3.13.7"
}
},
"nbformat": 4,
+6
View File
@@ -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
+19
View File
@@ -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",
)
),
],
)