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 ``` + ![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 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", + ) + ), + ], +)