diff --git a/examples/replay/README.md b/examples/replay/README.md new file mode 100644 index 0000000000..f4c7f5fdca --- /dev/null +++ b/examples/replay/README.md @@ -0,0 +1,16 @@ +# PraisonAI Replay: watch runs live + +| Example | What it shows | +|---|---| +| `watch_flow.py` | Click **Run** and watch each step light up on the map; **Play** goes through it again. No API key. | +| `watch_team.py` | The same for two real agents (needs `OPENAI_API_KEY`). | +| `embed_fastapi.py` | Your own app starts a run and shows its progress in an ` +""" diff --git a/examples/replay/watch_flow.py b/examples/replay/watch_flow.py new file mode 100644 index 0000000000..8a15b1ec6f --- /dev/null +++ b/examples/replay/watch_flow.py @@ -0,0 +1,40 @@ +"""Watch a flow run, step by step: click Run and each step lights up on the map. + +No API key needed (the steps are plain Python that take a moment each). + + pip install "praisonai[ui]" + python watch_flow.py # opens http://127.0.0.1:8090/ + +Map: dashed = not reached, blue = running now, green = done. +After a run, press Play to go through it again one step at a time. +""" + +import time + +from praisonaiagents import AgentFlow +from praisonaiagents.workflows import StepResult +from praisonai.replay import watch + + +def research(ctx): + time.sleep(1.5) + return StepResult(output=f"3 sources about {ctx.input}") + + +def outline(ctx): + time.sleep(1.5) + return StepResult(output="intro, body, close") + + +def draft(ctx): + time.sleep(1.5) + return StepResult(output="first draft") + + +def review(ctx): + time.sleep(1.5) + return StepResult(output="approved") + + +flow = AgentFlow(name="Write an article", steps=[research, outline, draft, review]) +watch(flow, input="solar sails") diff --git a/examples/replay/watch_team.py b/examples/replay/watch_team.py new file mode 100644 index 0000000000..3c55d136a2 --- /dev/null +++ b/examples/replay/watch_team.py @@ -0,0 +1,18 @@ +"""Watch two agents work together, live (calls the LLM: needs OPENAI_API_KEY). + + python watch_team.py # opens http://127.0.0.1:8090/, press Run +""" + +from praisonaiagents import Agent, AgentTeam, Task +from praisonai.replay import watch + +researcher = Agent(name="Researcher", instructions="Give 3 short facts.", llm="gpt-4o-mini") +writer = Agent(name="Writer", instructions="Write one short paragraph from the facts.", llm="gpt-4o-mini") +team = AgentTeam( + agents=[researcher, writer], + tasks=[ + Task(name="research", description="3 facts about solar sails", agent=researcher, expected_output="3 facts"), + Task(name="write", description="One paragraph using the facts", agent=writer, expected_output="a paragraph"), + ], +) +watch(team) diff --git a/src/praisonai/praisonai/cli/commands/replay.py b/src/praisonai/praisonai/cli/commands/replay.py index f5eb2a277d..eb8d9c5a1a 100644 --- a/src/praisonai/praisonai/cli/commands/replay.py +++ b/src/praisonai/praisonai/cli/commands/replay.py @@ -6,6 +6,7 @@ - replay context : Interactive replay of a trace - replay flow --format ascii|json|mermaid: Agent flow / static graph - replay view [run|yaml] [--live]: Open PraisonAI Replay (aiui) on a run +- replay watch [yaml]: A page with a Run button that draws each run live - replay export --format html|json|mermaid|otel: Export a run - replay rerun --from NODE: Re-run from a node, reusing earlier outputs """ @@ -1100,6 +1101,35 @@ def _load_raw_events(output, run: str): return events +@app.command("watch") +def replay_watch( + yaml_file: Optional[str] = typer.Argument(None, help="agents.yaml to run from the page (default: just view recorded runs)"), + port: int = typer.Option(8090, "--port", "-p", help="Port for the page"), + host: str = typer.Option("127.0.0.1", "--host", help="Host to bind to"), + no_browser: bool = typer.Option(False, "--no-browser", help="Do not open the browser"), +): + """Open a page with a Run button: each run is recorded and drawn live, step by step. + + Examples: + praisonai replay watch agents.yaml + praisonai replay watch # recorded runs only + """ + from praisonai.replay.live import watch + from praisonai.replay.yaml_run import is_yaml_path + + if not _aiui_installed(): + typer.echo(f"ERROR: PraisonAI Replay needs PraisonAI UI (aiui). Install with:\n {_AIUI_INSTALL_HINT}", err=True) + raise typer.Exit(1) + if yaml_file and not is_yaml_path(yaml_file): + typer.echo(f"ERROR: not a YAML file: {yaml_file}", err=True) + raise typer.Exit(2) + try: + watch(yaml_file, host=host, port=port, open_browser=not no_browser) + except ImportError as e: + typer.echo(f"ERROR: {e}", err=True) + raise typer.Exit(1) + + @app.command("export") def replay_export( run: str = typer.Argument(..., help="Run id or path to trace file"), diff --git a/src/praisonai/praisonai/replay/__init__.py b/src/praisonai/praisonai/replay/__init__.py index 04c5dbecac..7a454c8089 100644 --- a/src/praisonai/praisonai/replay/__init__.py +++ b/src/praisonai/praisonai/replay/__init__.py @@ -29,11 +29,18 @@ with record(live=True) as rec: agent.start("Say hello") html = export.to_html(load_events(rec.path)) + + # Watch it live: a page with a Run button (or mount it in your own app) + from praisonai.replay import watch, replay_app + watch(flow) # opens the browser + app.mount("/replay", replay_app(flow)) # FastAPI / Starlette """ __all__ = [ "record", "Recording", + "watch", + "replay_app", "TraceStepCache", "load_events", "ContextTraceWriter", @@ -57,6 +64,10 @@ def __getattr__(name: str): from . import recorder return getattr(recorder, name) + if name in ("watch", "replay_app"): + from . import live + return getattr(live, name) + if name == "TraceStepCache": from .rerun import TraceStepCache return TraceStepCache diff --git a/src/praisonai/praisonai/replay/live.py b/src/praisonai/praisonai/replay/live.py new file mode 100644 index 0000000000..02383635cb --- /dev/null +++ b/src/praisonai/praisonai/replay/live.py @@ -0,0 +1,138 @@ +"""Watch an agent, team or flow run live: a page with a Run button (PraisonAI Replay). + + from praisonai.replay import watch + watch(flow) # opens the browser; each Run is recorded and drawn live + + from praisonai.replay import replay_app + app.mount("/replay", replay_app(flow)) # inside your own FastAPI/Starlette app + +The page is aiui's Replay view without the dashboard around it +(``praisonaiui.features.replay.create_replay_app``). Requires ``pip install "praisonai[ui]"``. +""" + +from __future__ import annotations + +import asyncio +import inspect +import threading +import webbrowser +from pathlib import Path +from typing import Any, Callable, Optional + +_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"} +_INSTALL_HINT = 'PraisonAI Replay needs PraisonAI UI: pip install -U "praisonai[ui]"' + + +def _runner(target: Any) -> Optional[Callable[[str, str], None]]: + """``run(input, run_id)`` that records *target* under ``run_id``, live.""" + if target is None: + return None + from .recorder import record + from .yaml_run import is_yaml_path, run_yaml_recorded + + if isinstance(target, (str, Path)): + if not is_yaml_path(str(target)): + raise ValueError(f"not a YAML file: {target}") + path = str(target) + + def run_yaml(_text: str, run_id: str) -> None: + run_yaml_recorded(path, run_id=run_id, live=True) + + return run_yaml + + start = getattr(target, "start", None) + call = start if callable(start) else target + if not callable(call): + raise TypeError("target must be an Agent, AgentTeam, AgentFlow, a YAML path or a callable(input)") + + # An AgentTeam skips tasks it already completed and appends each input to + # every task's context; each Run starts them afresh with the original context. + reset = getattr(target, "_reset_task_state_for_item", None) + tasks = getattr(target, "tasks", None) + tasks = list(tasks.values()) if isinstance(tasks, dict) and callable(reset) else [] + contexts = [(t, list(t.context) if isinstance(getattr(t, "context", None), list) else None) for t in tasks] + + def run(text: str, run_id: str) -> None: + if callable(reset): + reset() + for task, context in contexts: + task.context = list(context) if context is not None else None + with record(run_id, live=True): + out = call(text) + if inspect.isawaitable(out): + asyncio.run(out) + + return run + + +def _title(target: Any) -> str: + if isinstance(target, (str, Path)): + return f"PraisonAI Replay: {Path(target).name}" + name = getattr(target, "name", None) or getattr(target, "__name__", None) + return f"PraisonAI Replay: {name}" if isinstance(name, str) and name else "PraisonAI Replay" + + +def replay_app(target: Any = None, *, input: str = "", placeholder: str = "", title: Optional[str] = None): + """The Replay page as an ASGI app; with *target*, it has a Run button. + + Args: + target: An Agent, AgentTeam or AgentFlow (``.start(input)`` is called), a + YAML file (run as written; no input box), or any ``callable(input)``. + ``None`` gives a read-only view of recorded runs. + input: Text the input box starts with. + placeholder: Placeholder for the input box. + title: Page title (default: from the target's name). + + Each Run is recorded with :func:`praisonai.replay.record` under a new run id + and the page follows it live. One run at a time. The same *target* object is + reused: a team's tasks start afresh each run, an Agent keeps its chat history. + + Open ``/`` for the run list, or ``/?run=`` for one run + (handy in an ``