From 4629a7527662e3c05b0e900d1466e5ed6e9979a8 Mon Sep 17 00:00:00 2001 From: MervinPraison <454862+MervinPraison@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:31:49 +0000 Subject: [PATCH 01/12] feat(replay): record() context manager and env-aware traces dir - Traces live in PRAISONAI_TRACES_DIR, else $PRAISON_HOME/traces (the root trace/last_run.py already uses), else ~/.praison/traces. - praisonai.replay.record(run_id=None, *, live=False, meta=None, full_content=None) owns writer + emitter + set_context_emitter and the session_start/session_end pair; live=True flushes every event (buffer_size=1). It yields .run_id / .path. - The three duplicated setup blocks (legacy praison_ai.py via the wrapper bridge, workflow run, recipe core) now use it. Recipe early returns (not found / missing deps / policy denied / dry run) used to leave the trace open and the emitter set; they now always close. - save_replay: true in agents.yaml / workflow YAML records like --save (added to YAMLConfig, schema artefact regenerated). YAML runs store yaml_path in session_start so a rerun can find the definition. - events.py: tolerant raw-dict reader (partial last line, unknown event types, schema 1.0 and 1.1) plus start/end span pairing. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../praisonai_code/cli/legacy/praison_ai.py | 70 ++-- .../praisonai/cli/legacy/workflow_commands.py | 67 ++-- .../praisonai/config/agents.schema.json | 13 + src/praisonai/praisonai/config/schema.py | 5 +- src/praisonai/praisonai/recipe/core.py | 88 ++--- src/praisonai/praisonai/replay/__init__.py | 25 ++ src/praisonai/praisonai/replay/events.py | 202 +++++++++++ src/praisonai/praisonai/replay/recorder.py | 148 ++++++++ src/praisonai/praisonai/replay/storage.py | 25 +- src/praisonai/praisonai/replay/yaml_run.py | 181 ++++++++++ src/praisonai/tests/unit/replay/conftest.py | 161 +++++++++ .../unit/replay/test_replay_storage_record.py | 324 ++++++++++++++++++ 12 files changed, 1162 insertions(+), 147 deletions(-) create mode 100644 src/praisonai/praisonai/replay/events.py create mode 100644 src/praisonai/praisonai/replay/recorder.py create mode 100644 src/praisonai/praisonai/replay/yaml_run.py create mode 100644 src/praisonai/tests/unit/replay/conftest.py create mode 100644 src/praisonai/tests/unit/replay/test_replay_storage_record.py diff --git a/src/praisonai-code/praisonai_code/cli/legacy/praison_ai.py b/src/praisonai-code/praisonai_code/cli/legacy/praison_ai.py index ee970e0c23..39342aa67a 100644 --- a/src/praisonai-code/praisonai_code/cli/legacy/praison_ai.py +++ b/src/praisonai-code/praisonai_code/cli/legacy/praison_ai.py @@ -889,29 +889,31 @@ def __init__(self): except Exception: pass # Continue even if flow display fails - # Initialize trace variables for cleanup - trace_writer = None - trace_emitter = None - trace_emitter_token = None - - # Get save flag for replay trace - save_replay = getattr(args, 'save', False) - - # Initialize replay trace writer if --save flag is set + # Replay trace: --save, or ``save_replay: true`` in the YAML. Recording + # lives in the wrapper (praisonai.replay.record); reach it through the + # bridge so a standalone praisonai-code install still runs, untraced. + import contextlib import uuid + from praisonai_code._wrapper_bridge import optional_wrapper_attr + run_id = f"run-{uuid.uuid4().hex[:12]}" + save_replay = getattr(args, 'save', False) + if not save_replay: + yaml_wants_replay = optional_wrapper_attr('praisonai.replay.yaml_run', 'yaml_wants_replay') + if yaml_wants_replay is not None: + save_replay = yaml_wants_replay(self.agent_file, self.agent_yaml) + + replay_stack = contextlib.ExitStack() + recording = None if save_replay: try: - from praisonai_code._wrapper_bridge import import_wrapper_module - _mod = import_wrapper_module('praisonai.replay') - ContextTraceWriter = getattr(_mod, 'ContextTraceWriter') - from praisonaiagents.trace.context_events import ContextTraceEmitter, set_context_emitter - - trace_writer = ContextTraceWriter(session_id=run_id) - trace_emitter = ContextTraceEmitter(sink=trace_writer, session_id=run_id) - # Set as global emitter so agents can access it - trace_emitter_token = set_context_emitter(trace_emitter) - trace_emitter.session_start({"agents_file": self.agent_file, "run_id": run_id}) + record = optional_wrapper_attr('praisonai.replay', 'record') + if record is None: + raise ImportError("praisonai.replay is not installed") + meta = {"agents_file": self.agent_file, "run_id": run_id} + if self.agent_file and os.path.isfile(self.agent_file): + meta["yaml_path"] = os.path.abspath(self.agent_file) + recording = replay_stack.enter_context(record(run_id, meta=meta)) print(f"[cyan]๐Ÿ“ Replay trace enabled: {run_id}[/cyan]") except ImportError as e: import logging @@ -919,8 +921,8 @@ def __init__(self): except Exception as e: import logging logging.warning(f"Failed to initialize trace writer: {e}") - - try: + + with replay_stack: AgentsGenerator = _get_agents_generator() # Extract CLI configuration for YAML CLI parity cli_config = self._extract_cli_config_for_yaml() @@ -934,29 +936,9 @@ def __init__(self): ) as agents_generator: result = agents_generator.generate_crew_and_kickoff() print(result) - - # Close trace writer on success - if trace_emitter: - trace_emitter.session_end() - print(f"[cyan]๐Ÿ“ Replay trace saved: {run_id}[/cyan]") - if trace_writer: - trace_writer.close() - # Reset global emitter - if trace_emitter_token: - from praisonaiagents.trace.context_events import reset_context_emitter - reset_context_emitter(trace_emitter_token) - - return result - except Exception as e: - # Cleanup trace on error - if trace_emitter: - trace_emitter.session_end() - if trace_writer: - trace_writer.close() - if trace_emitter_token: - from praisonaiagents.trace.context_events import reset_context_emitter - reset_context_emitter(trace_emitter_token) - raise + if recording is not None: + print(f"[cyan]๐Ÿ“ Replay trace saved: {run_id}[/cyan]") + return result def parse_args(self): """ diff --git a/src/praisonai/praisonai/cli/legacy/workflow_commands.py b/src/praisonai/praisonai/cli/legacy/workflow_commands.py index 219ed01309..08163bbf5d 100644 --- a/src/praisonai/praisonai/cli/legacy/workflow_commands.py +++ b/src/praisonai/praisonai/cli/legacy/workflow_commands.py @@ -329,11 +329,16 @@ def _run_yaml_workflow(self, yaml_file: str, action_args: list, variables: dict variables: Workflow variables args: Parsed command line arguments """ - # Initialize trace variables for cleanup - trace_writer = None - trace_emitter = None - trace_emitter_token = None - + # Replay trace (--save): praisonai.replay.record() owns writer + emitter setup. + import contextlib + replay_stack = contextlib.ExitStack() + recording = None + + def _finish_replay(failed: bool) -> None: + if recording is not None and failed: + recording.status = "failed" + replay_stack.close() + try: from praisonaiagents.workflows import WorkflowManager from rich import print @@ -362,22 +367,22 @@ def _run_yaml_workflow(self, yaml_file: str, action_args: list, variables: dict # Get save flag for replay trace save_replay = '--save' in action_args or '-s' in action_args or (getattr(args, 'save', False) if args else False) + if not save_replay: + # ``save_replay: true`` in the YAML records every run, like --save. + from praisonai.replay.yaml_run import yaml_wants_replay + save_replay = yaml_wants_replay(yaml_file) # Initialize replay trace writer if --save flag is set run_id = f"run-{uuid.uuid4().hex[:12]}" if save_replay: try: - from praisonai_code._wrapper_bridge import import_wrapper_module - _mod = import_wrapper_module('praisonai.replay') - ContextTraceWriter = getattr(_mod, 'ContextTraceWriter') - from praisonaiagents.trace.context_events import ContextTraceEmitter, set_context_emitter - from pathlib import Path - - trace_writer = ContextTraceWriter(session_id=run_id) - trace_emitter = ContextTraceEmitter(sink=trace_writer, session_id=run_id) - # Set as global emitter so agents can access it - trace_emitter_token = set_context_emitter(trace_emitter) - trace_emitter.session_start({"workflow": yaml_file, "run_id": run_id}) + import os as _os + from praisonai.replay.recorder import record + + meta = {"workflow": yaml_file, "run_id": run_id} + if _os.path.isfile(yaml_file): + meta["yaml_path"] = _os.path.abspath(yaml_file) + recording = replay_stack.enter_context(record(run_id, meta=meta)) print(f"[cyan]๐Ÿ“ Replay trace enabled: {run_id}[/cyan]") except ImportError as e: import logging @@ -512,39 +517,19 @@ def _run_yaml_workflow(self, yaml_file: str, action_args: list, variables: dict print(f"\n[red]โŒ Workflow failed: {result.get('error', 'Unknown error')}[/red]") exit_code = 1 - # Close trace writer on completion - if trace_emitter: - trace_emitter.session_end() + # Close the replay trace on completion + _finish_replay(failed=exit_code != 0) + if recording is not None: print(f"[cyan]๐Ÿ“ Replay trace saved: {run_id}[/cyan]") - if trace_writer: - trace_writer.close() - # Reset global emitter - if trace_emitter_token: - from praisonaiagents.trace.context_events import reset_context_emitter - reset_context_emitter(trace_emitter_token) return exit_code except FileNotFoundError: - # Cleanup trace on error - if trace_emitter: - trace_emitter.session_end() - if trace_writer: - trace_writer.close() - if trace_emitter_token: - from praisonaiagents.trace.context_events import reset_context_emitter - reset_context_emitter(trace_emitter_token) + _finish_replay(failed=True) print(f"[red]ERROR: YAML file not found: {yaml_file}[/red]") return 1 except Exception as e: - # Cleanup trace on error - if trace_emitter: - trace_emitter.session_end() - if trace_writer: - trace_writer.close() - if trace_emitter_token: - from praisonaiagents.trace.context_events import reset_context_emitter - reset_context_emitter(trace_emitter_token) + _finish_replay(failed=True) print(f"[red]ERROR: YAML workflow failed: {e}[/red]") import traceback traceback.print_exc() diff --git a/src/praisonai/praisonai/config/agents.schema.json b/src/praisonai/praisonai/config/agents.schema.json index e8b642ffde..f40f51fa72 100644 --- a/src/praisonai/praisonai/config/agents.schema.json +++ b/src/praisonai/praisonai/config/agents.schema.json @@ -1443,6 +1443,19 @@ "default": null, "description": "Task dependency declarations", "title": "Dependencies" + }, + "save_replay": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Record a replay trace for each run (praisonai replay)", + "title": "Save Replay" } }, "title": "PraisonAI Agents Configuration", diff --git a/src/praisonai/praisonai/config/schema.py b/src/praisonai/praisonai/config/schema.py index 052d5c0a46..70f4f4d8bb 100644 --- a/src/praisonai/praisonai/config/schema.py +++ b/src/praisonai/praisonai/config/schema.py @@ -402,7 +402,10 @@ class YAMLConfig(BaseModel): # Deployment / dependency declarations deploy: Optional[Dict[str, Any]] = Field(default=None, description="Deployment configuration") dependencies: Optional[Any] = Field(default=None, description="Task dependency declarations") - + + # PraisonAI Replay: record every run of this file (same as --save) + save_replay: Optional[bool] = Field(default=None, description="Record a replay trace for each run (praisonai replay)") + @model_validator(mode='after') def validate_config_structure(self): """Validate overall configuration structure.""" diff --git a/src/praisonai/praisonai/recipe/core.py b/src/praisonai/praisonai/recipe/core.py index 1f6ddf109a..a587d406cc 100644 --- a/src/praisonai/praisonai/recipe/core.py +++ b/src/praisonai/praisonai/recipe/core.py @@ -222,19 +222,19 @@ def _run_impl( "session_id": session_id, } - # Initialize replay trace writer if --save flag is set - trace_writer = None + # Replay trace (--save): praisonai.replay.record() owns writer + emitter + # setup and always closes it, including on the early-return paths below. + import contextlib + replay_stack = contextlib.ExitStack() + recording = None trace_emitter = None - trace_emitter_token = None # Token for resetting global emitter if options.get("save_replay", False): try: - from praisonai.replay import ContextTraceWriter - from praisonaiagents.trace.context_events import ContextTraceEmitter, set_context_emitter - trace_writer = ContextTraceWriter(session_id=run_id) - trace_emitter = ContextTraceEmitter(sink=trace_writer, session_id=run_id, full_content=True) - # Set as global emitter so agents can access it - trace_emitter_token = set_context_emitter(trace_emitter) - trace_emitter.session_start({"recipe": name, "run_id": run_id}) + from praisonai.replay.recorder import record + recording = replay_stack.enter_context( + record(run_id, meta={"recipe": name, "run_id": run_id}, full_content=True) + ) + trace_emitter = recording.emitter print(f"๐Ÿ“ Replay trace enabled: {run_id}") except ImportError as e: import logging @@ -242,12 +242,17 @@ def _run_impl( except Exception as e: import logging logging.warning(f"Failed to initialize trace writer: {e}") - + + def _replay_failed() -> None: + if recording is not None: + recording.status = "failed" + try: # Load template recipe_config = _load_recipe(name, offline=options.get("offline", False)) if recipe_config is None: + _replay_failed() return RecipeResult( run_id=run_id, recipe=name, @@ -263,6 +268,7 @@ def _run_impl( dep_result = _check_dependencies(recipe_config) if not dep_result["all_satisfied"]: missing = _format_missing_deps(dep_result) + _replay_failed() return RecipeResult( run_id=run_id, recipe=recipe_config.name, @@ -277,6 +283,7 @@ def _run_impl( if not options.get("allow_dangerous_tools", False): policy_error = _check_tool_policy(recipe_config, options) if policy_error: + _replay_failed() return RecipeResult( run_id=run_id, recipe=recipe_config.name, @@ -289,11 +296,6 @@ def _run_impl( # Dry run mode if options.get("dry_run", False): - # Close trace writer for dry-run - if trace_emitter: - trace_emitter.session_end() - if trace_writer: - trace_writer.close() return RecipeResult( run_id=run_id, recipe=recipe_config.name, @@ -335,16 +337,10 @@ def _run_impl( duration = time.time() - start_time - # Close trace writer on success - if trace_emitter: - trace_emitter.session_end() - if trace_writer: - trace_writer.close() + # Close the replay trace on success + replay_stack.close() + if recording is not None: print(f"๐Ÿ“ Replay trace saved: {run_id}") - # Reset global emitter - if trace_emitter_token: - from praisonaiagents.trace.context_events import reset_context_emitter - reset_context_emitter(trace_emitter_token) return RecipeResult( run_id=run_id, @@ -357,13 +353,7 @@ def _run_impl( ) except RecipeNotFoundError as e: - if trace_emitter: - trace_emitter.session_end() - if trace_writer: - trace_writer.close() - if trace_emitter_token: - from praisonaiagents.trace.context_events import reset_context_emitter - reset_context_emitter(trace_emitter_token) + _replay_failed() return RecipeResult( run_id=run_id, recipe=name, @@ -374,13 +364,7 @@ def _run_impl( trace=trace, ) except RecipeDependencyError as e: - if trace_emitter: - trace_emitter.session_end() - if trace_writer: - trace_writer.close() - if trace_emitter_token: - from praisonaiagents.trace.context_events import reset_context_emitter - reset_context_emitter(trace_emitter_token) + _replay_failed() return RecipeResult( run_id=run_id, recipe=e.recipe or name, @@ -391,13 +375,7 @@ def _run_impl( trace=trace, ) except RecipePolicyError as e: - if trace_emitter: - trace_emitter.session_end() - if trace_writer: - trace_writer.close() - if trace_emitter_token: - from praisonaiagents.trace.context_events import reset_context_emitter - reset_context_emitter(trace_emitter_token) + _replay_failed() return RecipeResult( run_id=run_id, recipe=e.recipe or name, @@ -408,13 +386,7 @@ def _run_impl( trace=trace, ) except RecipeTimeoutError as e: - if trace_emitter: - trace_emitter.session_end() - if trace_writer: - trace_writer.close() - if trace_emitter_token: - from praisonaiagents.trace.context_events import reset_context_emitter - reset_context_emitter(trace_emitter_token) + _replay_failed() return RecipeResult( run_id=run_id, recipe=name, @@ -425,13 +397,7 @@ def _run_impl( trace=trace, ) except Exception as e: - if trace_emitter: - trace_emitter.session_end() - if trace_writer: - trace_writer.close() - if trace_emitter_token: - from praisonaiagents.trace.context_events import reset_context_emitter - reset_context_emitter(trace_emitter_token) + _replay_failed() return RecipeResult( run_id=run_id, recipe=name, @@ -441,6 +407,8 @@ def _run_impl( metrics={"duration_sec": time.time() - start_time}, trace=trace, ) + finally: + replay_stack.close() def run_stream( diff --git a/src/praisonai/praisonai/replay/__init__.py b/src/praisonai/praisonai/replay/__init__.py index 8b4f4f8b90..04c5dbecac 100644 --- a/src/praisonai/praisonai/replay/__init__.py +++ b/src/praisonai/praisonai/replay/__init__.py @@ -20,9 +20,22 @@ # Interactive replay player = ReplayPlayer(reader) player.run() + + # Record a run (PraisonAI Replay) and export it + from praisonai.replay import record + from praisonai.replay.events import load_events + from praisonai.replay import export + + with record(live=True) as rec: + agent.start("Say hello") + html = export.to_html(load_events(rec.path)) """ __all__ = [ + "record", + "Recording", + "TraceStepCache", + "load_events", "ContextTraceWriter", "ContextTraceReader", "ReplayPlayer", @@ -40,6 +53,18 @@ def __getattr__(name: str): """Lazy load module components.""" + if name in ("record", "Recording"): + from . import recorder + return getattr(recorder, name) + + if name == "TraceStepCache": + from .rerun import TraceStepCache + return TraceStepCache + + if name == "load_events": + from .events import load_events + return load_events + if name == "ContextTraceWriter": from .writer import ContextTraceWriter return ContextTraceWriter diff --git a/src/praisonai/praisonai/replay/events.py b/src/praisonai/praisonai/replay/events.py new file mode 100644 index 0000000000..4bfa3e7ec7 --- /dev/null +++ b/src/praisonai/praisonai/replay/events.py @@ -0,0 +1,202 @@ +"""Raw trace-event helpers shared by export, rerun and the replay CLI. + +Events are handled as plain dicts (one ``ContextEvent.to_dict()`` per JSONL +line) rather than ``ContextEvent`` objects, so that: + +- event types this version of ``praisonaiagents`` does not know survive intact + (``ContextEvent.from_dict`` may relabel them); +- schema 1.1 fields (``run_id``, ``parent_seq``, ``node_id``) are read when + present and simply absent on 1.0 traces; +- a partially written last line (a live recording) is skipped, not fatal. + +Stdlib only. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple, Union + +from .storage import get_trace_path, resolve_traces_dir + +__all__ = [ + "SAFE_RUN_ID", + "SPAN_PAIRS", + "as_dicts", + "etype", + "find_graph", + "load_events", + "pair_spans", + "resolve_trace_path", + "run_id_of", + "session_meta", +] + +#: Run ids that are safe to use as file names and URL fragments (contract ยง5). +SAFE_RUN_ID = re.compile(r"^[A-Za-z0-9._-]+$") + +#: start event type -> matching end event type. +SPAN_PAIRS: Dict[str, str] = { + "session_start": "session_end", + "workflow_start": "workflow_end", + "task_start": "task_end", + "step_start": "step_end", + "agent_start": "agent_end", + "llm_request": "llm_response", + "tool_call_start": "tool_call_end", + "approval_requested": "approval_decided", +} +_END_TO_START = {end: start for start, end in SPAN_PAIRS.items()} + +EventLike = Union[Dict[str, Any], Any] + + +def etype(event: Dict[str, Any]) -> str: + """The raw event type string (an unknown type is kept as-is).""" + data = event.get("data") or {} + raw = data.get("_raw_event_type") if isinstance(data, dict) else None + value = raw or event.get("event_type") or "" + return getattr(value, "value", value) + + +def as_dicts(events: Iterable[EventLike]) -> List[Dict[str, Any]]: + """Normalise ``ContextEvent`` objects or dicts to plain dicts.""" + out: List[Dict[str, Any]] = [] + for ev in events or []: + if isinstance(ev, dict): + out.append(ev) + elif hasattr(ev, "to_dict"): + out.append(ev.to_dict()) + return out + + +def resolve_trace_path(target: Union[str, Path], traces_dir: Optional[Path] = None) -> Path: + """Map a run id, session id or JSONL path to the trace file path.""" + text = str(target) + if text.endswith(".jsonl") or "/" in text or "\\" in text: + return Path(text).expanduser() + return get_trace_path(text, Path(traces_dir) if traces_dir else resolve_traces_dir()) + + +def load_events(target: Union[str, Path], traces_dir: Optional[Path] = None) -> List[Dict[str, Any]]: + """Read every complete event of a trace as dicts, in file order. + + Blank lines and lines that are not valid JSON objects (typically the + still-being-written last line of a live trace) are skipped. + + Raises: + FileNotFoundError: if the trace does not exist. + """ + path = resolve_trace_path(target, traces_dir) + if not path.exists(): + raise FileNotFoundError(f"Trace not found: {target}") + events: List[Dict[str, Any]] = [] + with open(path, "r", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except ValueError: + continue + if isinstance(obj, dict): + events.append(obj) + return events + + +def run_id_of(events: List[Dict[str, Any]], default: str = "run") -> str: + """The run id of a trace: first ``run_id`` field, else ``session_id``.""" + for ev in events: + if ev.get("run_id"): + return str(ev["run_id"]) + for ev in events: + if ev.get("session_id"): + return str(ev["session_id"]) + return default + + +def session_meta(events: List[Dict[str, Any]]) -> Dict[str, Any]: + """The ``data`` of the first ``session_start`` event (recording metadata).""" + for ev in events: + if etype(ev) == "session_start": + data = ev.get("data") + return dict(data) if isinstance(data, dict) else {} + return {} + + +def find_graph(events: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """The static graph embedded in the first ``workflow_start`` (schema 1.1).""" + for ev in events: + if etype(ev) == "workflow_start": + graph = (ev.get("data") or {}).get("graph") + if isinstance(graph, dict): + return graph + return None + + +def _seq(event: Dict[str, Any], index: int) -> int: + seq = event.get("sequence_num") + return seq if isinstance(seq, int) else index + + +def _same_subject(start: Dict[str, Any], end: Dict[str, Any]) -> bool: + """Heuristic match for 1.0 traces that carry no ``parent_seq``.""" + if start.get("agent_name") != end.get("agent_name") and end.get("agent_name"): + return False + s_data, e_data = start.get("data") or {}, end.get("data") or {} + for key in ("tool_name", "name"): + if s_data.get(key) and e_data.get(key) and s_data[key] != e_data[key]: + return False + if start.get("node_id") and end.get("node_id") and start["node_id"] != end["node_id"]: + return False + return True + + +def pair_spans(events: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Turn start/end event pairs into spans. + + Returns ``(spans, points)``. Each span is + ``{"start": ev, "end": ev|None, "seq": int, "parent": int|None, "type": str}`` + where ``parent`` is the ``seq`` of the enclosing span. ``points`` are events + that are neither a start nor an end (messages, hand-offs, snapshots...). + + Schema 1.1 traces are paired exactly through ``parent_seq``; 1.0 traces fall + back to a stack (innermost open span of the same kind and subject). + """ + spans: Dict[int, Dict[str, Any]] = {} + order: List[int] = [] + points: List[Dict[str, Any]] = [] + open_stack: List[int] = [] + + for index, ev in enumerate(events): + kind = etype(ev) + seq = _seq(ev, index) + if kind in SPAN_PAIRS: + parent = ev.get("parent_seq") + if not isinstance(parent, int): + parent = open_stack[-1] if open_stack else None + spans[seq] = {"start": ev, "end": None, "seq": seq, "parent": parent, "type": kind} + order.append(seq) + open_stack.append(seq) + elif kind in _END_TO_START: + start_kind = _END_TO_START[kind] + target = ev.get("parent_seq") + if not (isinstance(target, int) and target in spans and spans[target]["end"] is None): + target = None + for cand in reversed(open_stack): + span = spans[cand] + if span["type"] == start_kind and _same_subject(span["start"], ev): + target = cand + break + if target is None: + points.append(ev) + continue + spans[target]["end"] = ev + if target in open_stack: + open_stack.remove(target) + else: + points.append(ev) + return [spans[s] for s in order], points diff --git a/src/praisonai/praisonai/replay/recorder.py b/src/praisonai/praisonai/replay/recorder.py new file mode 100644 index 0000000000..1783239312 --- /dev/null +++ b/src/praisonai/praisonai/replay/recorder.py @@ -0,0 +1,148 @@ +"""Record a run to a replay trace: ``with praisonai.replay.record(): ...``. + +One place owns the writer + emitter + context-var setup that the CLI, the +workflow runner and recipes used to duplicate:: + + from praisonai.replay import record + + with record(live=True) as rec: + Agent(name="a", instructions="...").start("Say hello") + print(rec.run_id, rec.path) + +Everything the emitter records is redacted by default (the emitter's own +``redact=True``). ``live=True`` writes every event to disk as it happens +(``buffer_size=1``) so ``praisonai replay view --live`` can follow the run. +""" + +from __future__ import annotations + +import contextlib +import inspect +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, Iterator, Optional + +__all__ = ["Recording", "record", "new_run_id"] + +#: Events buffered before a flush when not recording live (the writer's default). +BATCH_BUFFER_SIZE = 100 + + +def new_run_id() -> str: + """A fresh run id in the format the CLI has always used (``run-<12 hex>``).""" + return f"run-{uuid.uuid4().hex[:12]}" + + +@dataclass +class Recording: + """Handle yielded by :func:`record`.""" + + run_id: str + path: Path + emitter: Any = field(repr=False, default=None) + writer: Any = field(repr=False, default=None) + live: bool = False + #: Set to ``"failed"`` when the caller handled a failure itself, so + #: ``session_end`` still reports it (an escaping exception does this too). + status: Optional[str] = None + + +class _ForkTagSink: + """Sink wrapper that stamps ``forked_from`` on the run's first ``workflow_start``.""" + + def __init__(self, inner: Any, forked_from: Dict[str, Any]): + self._inner = inner + self._forked_from = dict(forked_from) + self._tagged = False + + def emit(self, event: Any) -> None: + if not self._tagged: + kind = getattr(event, "event_type", None) + kind = getattr(kind, "value", kind) + if kind == "workflow_start": + data = dict(getattr(event, "data", None) or {}) + data.setdefault("forked_from", dict(self._forked_from)) + try: + event.data = data + except Exception: + pass + self._tagged = True + self._inner.emit(event) + + def flush(self) -> None: + self._inner.flush() + + def close(self) -> None: + self._inner.close() + + +def _make_emitter(sink: Any, run_id: str, full_content: bool): + from praisonaiagents.trace.context_events import ContextTraceEmitter + + kwargs: Dict[str, Any] = {"sink": sink, "session_id": run_id, "full_content": full_content} + # Schema 1.1 emitters stamp ``run_id`` on every event; older ones do not + # take it. The session id equals the run id either way. + try: + if "run_id" in inspect.signature(ContextTraceEmitter.__init__).parameters: + kwargs["run_id"] = run_id + except (TypeError, ValueError): + pass + return ContextTraceEmitter(**kwargs) + + +@contextlib.contextmanager +def record( + run_id: Optional[str] = None, + *, + live: bool = False, + meta: Optional[Dict[str, Any]] = None, + full_content: Optional[bool] = None, + _forked_from: Optional[Dict[str, Any]] = None, +) -> Iterator[Recording]: + """Record everything run inside the block to ``/.jsonl``. + + Args: + run_id: Trace id and file name (default: ``run-<12 hex>``). + live: Flush every event immediately so a viewer can follow the run. + meta: Extra ``session_start`` data (e.g. ``{"yaml_path": ...}``, which + ``praisonai replay rerun`` uses to find the definition again). + full_content: Keep tool results / LLM responses untruncated. + + Yields: + :class:`Recording` with ``run_id`` and ``path``. + """ + from praisonaiagents.trace.context_events import reset_context_emitter, set_context_emitter + + from .writer import ContextTraceWriter + + run_id = run_id or new_run_id() + writer = ContextTraceWriter( + session_id=run_id, buffer_size=1 if live else BATCH_BUFFER_SIZE + ) + sink: Any = _ForkTagSink(writer, _forked_from) if _forked_from else writer + emitter = _make_emitter(sink, run_id, bool(full_content)) + token = set_context_emitter(emitter) + started = time.time() + rec = Recording(run_id=run_id, path=writer.path, emitter=emitter, writer=writer, live=live) + + start_meta: Dict[str, Any] = dict(meta or {}) + start_meta.setdefault("run_id", run_id) + if _forked_from: + start_meta.setdefault("forked_from", dict(_forked_from)) + status = "completed" + try: + emitter.session_start(start_meta) + yield rec + except BaseException: + status = "failed" + raise + finally: + try: + emitter.session_end( + {"status": rec.status or status, "duration_ms": round((time.time() - started) * 1000, 1)} + ) + finally: + writer.close() + reset_context_emitter(token) diff --git a/src/praisonai/praisonai/replay/storage.py b/src/praisonai/praisonai/replay/storage.py index 8b16ea4a62..beab5f9383 100644 --- a/src/praisonai/praisonai/replay/storage.py +++ b/src/praisonai/praisonai/replay/storage.py @@ -15,6 +15,29 @@ DEFAULT_TRACES_DIR = "~/.praison/traces" +#: Overrides the traces directory outright (shared with the aiui replay view). +TRACES_DIR_ENV = "PRAISONAI_TRACES_DIR" +#: State root shared with ``praisonaiagents.trace.last_run`` (``$PRAISON_HOME/traces``). +PRAISON_HOME_ENV = "PRAISON_HOME" + + +def resolve_traces_dir() -> Path: + """Where traces live, without creating anything. + + Precedence (read live on every call so tests and sandboxes can redirect it): + + 1. ``PRAISONAI_TRACES_DIR`` + 2. ``$PRAISON_HOME/traces`` (the same root ``trace/last_run.py`` uses) + 3. ``~/.praison/traces`` + """ + override = os.environ.get(TRACES_DIR_ENV) + if override: + return Path(os.path.expanduser(override)) + home = os.environ.get(PRAISON_HOME_ENV) + if home: + return Path(os.path.expanduser(home)) / "traces" + return Path(os.path.expanduser(DEFAULT_TRACES_DIR)) + def get_traces_dir() -> Path: """ @@ -23,7 +46,7 @@ def get_traces_dir() -> Path: Returns: Path to traces directory (creates if not exists) """ - traces_dir = Path(os.path.expanduser(DEFAULT_TRACES_DIR)) + traces_dir = resolve_traces_dir() traces_dir.mkdir(parents=True, exist_ok=True) return traces_dir diff --git a/src/praisonai/praisonai/replay/yaml_run.py b/src/praisonai/praisonai/replay/yaml_run.py new file mode 100644 index 0000000000..1cdc9ddd24 --- /dev/null +++ b/src/praisonai/praisonai/replay/yaml_run.py @@ -0,0 +1,181 @@ +"""YAML definitions for replay: their static graph, and running them recorded. + +- :func:`static_graph` / :func:`static_mermaid` picture an ``agents.yaml`` or + workflow YAML *before* it runs (``praisonai replay flow file.yaml``). +- :func:`run_yaml_recorded` runs one under :func:`praisonai.replay.record`, + storing the YAML path in ``session_start`` so ``replay rerun`` can find the + definition again (``praisonai replay view file.yaml``). +""" + +from __future__ import annotations + +import contextlib +from pathlib import Path +from typing import Any, Callable, Dict, Optional, Tuple, Union + +__all__ = [ + "is_yaml_path", + "load_flow", + "static_graph", + "static_mermaid", + "run_yaml_recorded", + "yaml_kind", + "yaml_meta", + "yaml_wants_replay", +] + +GRAPH_SCHEMA = "praisonai.graph/1" + + +def is_yaml_path(target: Union[str, Path, None]) -> bool: + """True for an existing ``.yaml``/``.yml`` file.""" + if not target: + return False + path = Path(str(target)).expanduser() + return path.suffix.lower() in (".yaml", ".yml") and path.is_file() + + +def yaml_meta(yaml_path: Union[str, Path]) -> Dict[str, Any]: + """``session_start`` metadata that lets ``replay rerun`` find the definition.""" + resolved = str(Path(yaml_path).expanduser().resolve()) + return {"yaml_path": resolved, "agents_file": resolved} + + +def yaml_wants_replay(yaml_path: Optional[Union[str, Path]] = None, yaml_text: Optional[str] = None) -> bool: + """True when the YAML sets ``save_replay: true`` at the top level.""" + try: + import yaml + + if yaml_text is None: + if not yaml_path or not Path(yaml_path).is_file(): + return False + yaml_text = Path(yaml_path).read_text(encoding="utf-8") + data = yaml.safe_load(yaml_text) + except Exception: + return False + return isinstance(data, dict) and data.get("save_replay") is True + + +def yaml_kind(yaml_path: Union[str, Path]) -> str: + """``"flow"`` when ``praisonai file.yaml`` would run it as a workflow, else ``"team"``. + + Same rule as ``AgentsGenerator._is_workflow_yaml``. + """ + import yaml + + data = yaml.safe_load(Path(yaml_path).expanduser().read_text(encoding="utf-8")) or {} + if not isinstance(data, dict): + return "team" + is_flow = ( + data.get("process", "sequential") == "workflow" + or ("steps" in data and "workflow" in data) + or data.get("type") in {"job", "hybrid"} + ) + return "flow" if is_flow else "team" + + +def load_flow(yaml_path: Union[str, Path]) -> Any: + """Parse a YAML definition into an AgentFlow with the SDK's own parser (no run).""" + from praisonaiagents.workflows.yaml_parser import YAMLWorkflowParser + + return YAMLWorkflowParser().parse_file(Path(yaml_path).expanduser()) + + +def _core_diagram(name: str) -> Optional[Callable[..., Any]]: + try: + from praisonaiagents.workflows import diagram + except Exception: + return None + fn = getattr(diagram, name, None) + return fn if callable(fn) else None + + +def _fallback_graph(flow: Any) -> Dict[str, Any]: + """A minimal ``praisonai.graph/1`` (linear steps) for SDKs without ``to_graph``.""" + nodes = [{"id": "start:begin", "kind": "start", "label": "Brief", "parent": None, "meta": {}}] + edges = [] + seen: Dict[str, int] = {} + prev = "start:begin" + for step in getattr(flow, "steps", None) or []: + name = getattr(step, "name", None) or getattr(step, "__name__", None) or type(step).__name__ + seen[name] = seen.get(name, 0) + 1 + node_id = f"step:{name}" if seen[name] == 1 else f"step:{name}#{seen[name]}" + nodes.append({"id": node_id, "kind": "step", "label": str(name), "parent": None, "meta": {}}) + edges.append({"id": f"{prev}->{node_id}:next", "source": prev, "target": node_id, "kind": "next", "label": None}) + prev = node_id + nodes.append({"id": "end:done", "kind": "end", "label": "Delivered", "parent": None, "meta": {}}) + edges.append({"id": f"{prev}->end:done:next", "source": prev, "target": "end:done", "kind": "next", "label": None}) + return { + "schema": GRAPH_SCHEMA, + "kind": "flow", + "name": getattr(flow, "name", None) or "workflow", + "process": "workflow", + "nodes": nodes, + "edges": edges, + } + + +def static_graph(yaml_path: Union[str, Path]) -> Tuple[Dict[str, Any], Any]: + """``(graph, flow)`` for a YAML definition, without running it. + + Uses ``praisonaiagents.workflows.diagram.to_graph`` when this SDK has it + (schema ``praisonai.graph/1``); otherwise a linear fallback graph. + """ + flow = load_flow(yaml_path) + to_graph = _core_diagram("to_graph") + if to_graph is not None: + try: + graph = to_graph(flow) + if isinstance(graph, dict): + return graph, flow + except Exception: + pass + return _fallback_graph(flow), flow + + +def static_mermaid(yaml_path: Union[str, Path]) -> str: + """Mermaid for a YAML definition: ``graph_to_mermaid`` when available, else ``AgentFlow.to_mermaid()``.""" + graph, flow = static_graph(yaml_path) + graph_to_mermaid = _core_diagram("graph_to_mermaid") + if graph_to_mermaid is not None and graph.get("schema") == GRAPH_SCHEMA: + try: + return graph_to_mermaid(graph) + except Exception: + pass + return flow.to_mermaid() + + +def run_yaml_recorded( + yaml_path: Union[str, Path], + *, + run_id: Optional[str] = None, + live: bool = False, + plan: Any = None, + on_seeded: Optional[Callable[[Any], None]] = None, +) -> Tuple[Any, Any]: + """Run a YAML definition the way ``praisonai file.yaml`` does, recorded. + + Args: + plan: a :class:`praisonai.replay.rerun.RerunPlan` to re-run from a node. + on_seeded: called with what the plan restored (TraceStepCache or task keys). + + Returns: + ``(recording, result)``. + """ + from praisonai.agents_generator import AgentsGenerator + from praisonai_code.llm.config import build_config_list + + from .recorder import record + + path = Path(yaml_path).expanduser().resolve() + forked = plan.forked_from if plan is not None else None + with record(run_id, live=live, meta=yaml_meta(path), _forked_from=forked) as rec: + scope: Any = contextlib.nullcontext() + if plan is not None: + from .rerun import rerun_scope + + scope = rerun_scope(plan, on_seeded) + with scope: + with AgentsGenerator(str(path), "", build_config_list()) as generator: + result = generator.generate_crew_and_kickoff() + return rec, result diff --git a/src/praisonai/tests/unit/replay/conftest.py b/src/praisonai/tests/unit/replay/conftest.py new file mode 100644 index 0000000000..8826177ecd --- /dev/null +++ b/src/praisonai/tests/unit/replay/conftest.py @@ -0,0 +1,161 @@ +"""Fixtures for PraisonAI Replay tests: an isolated traces dir and contract-format traces.""" + +import json +from pathlib import Path + +import pytest + + +@pytest.fixture +def traces_dir(tmp_path, monkeypatch): + """Point every trace read/write at a temp dir (never the developer's ~/.praison).""" + d = tmp_path / "traces" + monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(d)) + monkeypatch.delenv("PRAISON_HOME", raising=False) + return d + + +def write_trace(directory: Path, run_id: str, events, partial_tail: str = "") -> Path: + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{run_id}.jsonl" + with open(path, "w", encoding="utf-8") as fh: + for ev in events: + fh.write(json.dumps(ev) + "\n") + if partial_tail: + fh.write(partial_tail) # a live trace's half-written last line + return path + + +def _ev(seq, event_type, *, agent=None, data=None, parent=None, node=None, run_id="run-team01", t0=1_700_000_000.0, **extra): + ev = { + "schema_version": "1.1", + "event_type": event_type, + "timestamp": t0 + seq * 0.5, + "session_id": run_id, + "agent_name": agent, + "sequence_num": seq, + "messages_count": 0, + "tokens_used": 0, + "tokens_budget": 0, + "data": data or {}, + "prompt_tokens": 0, + "completion_tokens": 0, + "cost_usd": 0.0, + "run_id": run_id, + } + if parent is not None: + ev["parent_seq"] = parent + if node is not None: + ev["node_id"] = node + ev.update(extra) + return ev + + +GRAPH = { + "schema": "praisonai.graph/1", "kind": "team", "name": "Blog team", "process": "sequential", + "nodes": [ + {"id": "start:begin", "kind": "start", "label": "Brief", "parent": None, "meta": {}}, + {"id": "task:research", "kind": "task", "label": "research", "parent": None, "meta": {}}, + {"id": "task:write", "kind": "task", "label": "write", "parent": None, "meta": {}}, + {"id": "end:done", "kind": "end", "label": "Delivered", "parent": None, "meta": {}}, + ], + "edges": [ + {"id": "start:begin->task:research:next", "source": "start:begin", "target": "task:research", "kind": "next", "label": None}, + {"id": "task:research->task:write:next", "source": "task:research", "target": "task:write", "kind": "next", "label": None}, + {"id": "task:write->end:done:next", "source": "task:write", "target": "end:done", "kind": "next", "label": None}, + ], +} + + +def team_events(run_id="run-team01", yaml_path=None): + """A schema 1.1 sequential team run: research -> (handoff) -> write, with an approval.""" + meta = {"run_id": run_id} + if yaml_path: + meta["yaml_path"] = str(yaml_path) + e = lambda *a, **k: _ev(*a, run_id=run_id, **k) # noqa: E731 + return [ + e(0, "session_start", data=meta), + e(1, "workflow_start", parent=0, data={"kind": "team", "name": "Blog team", "process": "sequential", "graph": GRAPH}), + e(2, "task_start", parent=1, node="task:research", + data={"name": "research", "description_preview": "Research AI", "agent": "Researcher"}), + e(3, "agent_start", agent="Researcher", parent=2, node="agent:Researcher"), + e(4, "llm_request", agent="Researcher", parent=3, data={"model": "gpt-4o-mini"}), + e(5, "llm_response", agent="Researcher", parent=4, prompt_tokens=10, completion_tokens=5, cost_usd=0.0001, + data={"model": "gpt-4o-mini", "duration_ms": 12.0, "finish_reason": "stop", + "response_content": "Findings: AI is big"}), + e(6, "tool_call_start", agent="Researcher", parent=3, node="tool:Researcher/web_search", + data={"tool_name": "web_search", "tool_args": {"query": "ai", "api_key": "sk-abcdefghijklmnop1234"}}), + e(7, "tool_call_end", agent="Researcher", parent=6, node="tool:Researcher/web_search", + data={"tool_name": "web_search", "result": "contact bob@example.com", "duration_ms": 3.0}), + e(8, "agent_end", agent="Researcher", parent=3, node="agent:Researcher"), + e(9, "task_end", parent=2, node="task:research", + data={"status": "completed", "retries": 0, "duration_ms": 3500, "output_preview": "Findings: AI is big"}), + e(10, "agent_handoff", agent="Researcher", parent=1, + data={"from_agent": "Researcher", "to_agent": "Writer", "reason": "draft it", "context_passed": {}, "kind": "handoff"}), + e(11, "task_start", parent=1, node="task:write", + data={"name": "write", "description_preview": "Write post", "agent": "Writer"}), + e(12, "approval_requested", agent="Writer", parent=11, node="tool:Writer/publish", + data={"approval_id": "ap1", "tool": "publish", "args": {}, "risk": "high", "kind": "tool_call"}), + e(13, "approval_decided", agent="Writer", parent=12, node="tool:Writer/publish", + data={"approval_id": "ap1", "approved": True, "reason": "ok", "approver": "you", "modified": False}), + e(14, "task_end", parent=11, node="task:write", + data={"status": "completed", "retries": 0, "duration_ms": 1000, "output_preview": "Post"}), + e(15, "workflow_end", parent=1, data={"status": "completed", "duration_ms": 7000}), + e(16, "session_end", parent=0, data={"status": "completed"}), + ] + + +def legacy_events(run_id="run-legacy1"): + """A schema 1.0 trace (no run_id / parent_seq / node_id), as written before Replay.""" + base = dict(session_id=run_id, messages_count=0, tokens_used=0, tokens_budget=0, + prompt_tokens=0, completion_tokens=0, cost_usd=0.0, schema_version="1.0") + rows = [ + ("session_start", None, {"agents_file": "agents.yaml", "run_id": run_id}), + ("agent_start", "Researcher", {}), + ("llm_request", "Researcher", {"model": "gpt-4o-mini"}), + ("llm_response", "Researcher", {"response_tokens": 5, "duration_ms": 10.0, "finish_reason": "stop"}), + ("agent_end", "Researcher", {}), + ("agent_handoff", "Researcher", {"from_agent": "Researcher", "to_agent": "Writer", "reason": "next", "context_passed": {}}), + ("agent_start", "Writer", {}), + ("agent_end", "Writer", {}), + ("session_end", None, {}), + ] + return [ + dict(base, event_type=t, agent_name=a, data=d, sequence_num=i, timestamp=1_600_000_000.0 + i) + for i, (t, a, d) in enumerate(rows) + ] + + +def flow_events(run_id="run-flow01", yaml_path=None, step_names=("research", "draft", "polish"), outputs=None): + """A schema 1.1 AgentFlow run of handler steps.""" + outputs = outputs or {n: f"{n} output" for n in step_names} + meta = {"run_id": run_id} + if yaml_path: + meta["yaml_path"] = str(yaml_path) + e = lambda *a, **k: _ev(*a, run_id=run_id, **k) # noqa: E731 + events = [e(0, "session_start", data=meta), e(1, "workflow_start", parent=0, data={"kind": "flow", "name": "f", "process": "workflow"})] + seq = 2 + for name in step_names: + start = seq + events.append(e(seq, "step_start", parent=1, node=f"step:{name}", data={"name": name})) + seq += 1 + events.append(e(seq, "step_end", parent=start, node=f"step:{name}", + data={"status": "completed", "duration_ms": 1, "output_preview": outputs[name]})) + seq += 1 + events.append(e(seq, "workflow_end", parent=1, data={"status": "completed", "duration_ms": 9})) + events.append(e(seq + 1, "session_end", parent=0, data={"status": "completed"})) + return events + + +@pytest.fixture +def fx(): + """Trace builders: ``fx.team_events()``, ``fx.legacy_events()``, ``fx.flow_events()``, ``fx.write_trace()``.""" + import types + + return types.SimpleNamespace( + team_events=team_events, + legacy_events=legacy_events, + flow_events=flow_events, + write_trace=write_trace, + GRAPH=GRAPH, + ) diff --git a/src/praisonai/tests/unit/replay/test_replay_storage_record.py b/src/praisonai/tests/unit/replay/test_replay_storage_record.py new file mode 100644 index 0000000000..f9df2b2cea --- /dev/null +++ b/src/praisonai/tests/unit/replay/test_replay_storage_record.py @@ -0,0 +1,324 @@ +"""PraisonAI Replay: trace location precedence and ``praisonai.replay.record()``.""" + +import json +from pathlib import Path + +import pytest + + +# --------------------------------------------------------------------------- +# storage: PRAISONAI_TRACES_DIR > $PRAISON_HOME/traces > ~/.praison/traces +# --------------------------------------------------------------------------- + +def test_traces_dir_env_wins_over_praison_home(tmp_path, monkeypatch): + from praisonai.replay.storage import get_traces_dir + + monkeypatch.setenv("PRAISON_HOME", str(tmp_path / "home")) + monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(tmp_path / "explicit")) + assert get_traces_dir() == tmp_path / "explicit" + assert (tmp_path / "explicit").is_dir() + + +def test_traces_dir_uses_praison_home(tmp_path, monkeypatch): + from praisonai.replay.storage import get_trace_path, get_traces_dir + + monkeypatch.delenv("PRAISONAI_TRACES_DIR", raising=False) + monkeypatch.setenv("PRAISON_HOME", str(tmp_path / "home")) + assert get_traces_dir() == tmp_path / "home" / "traces" + assert get_trace_path("abc") == tmp_path / "home" / "traces" / "abc.jsonl" + + +def test_traces_dir_default_is_home_dot_praison(tmp_path, monkeypatch): + from praisonai.replay.storage import resolve_traces_dir + + monkeypatch.delenv("PRAISONAI_TRACES_DIR", raising=False) + monkeypatch.delenv("PRAISON_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + assert resolve_traces_dir() == tmp_path / ".praison" / "traces" + + +def test_praison_home_matches_last_run_pointer_root(tmp_path, monkeypatch): + """Replay traces and the last-run pointer share one state root.""" + from praisonaiagents.trace.last_run import last_run_pointer_path + from praisonai.replay.storage import resolve_traces_dir + + monkeypatch.delenv("PRAISONAI_TRACES_DIR", raising=False) + monkeypatch.setenv("PRAISON_HOME", str(tmp_path)) + assert resolve_traces_dir().parent == last_run_pointer_path().parent + + +def test_writer_and_list_follow_env(traces_dir): + from praisonai.replay import ContextTraceWriter, list_traces + + w = ContextTraceWriter(session_id="envrun") + assert w.path == traces_dir / "envrun.jsonl" + w.close() + w.path.write_text('{"event_type": "session_start"}\n') + assert [t.session_id for t in list_traces()] == ["envrun"] + + +# --------------------------------------------------------------------------- +# record() +# --------------------------------------------------------------------------- + +def _lines(path): + return [json.loads(l) for l in Path(path).read_text().splitlines() if l.strip()] + + +def test_record_writes_session_start_and_end(traces_dir): + from praisonaiagents.trace.context_events import get_context_emitter + from praisonai.replay import record + + with record(meta={"yaml_path": "/x/agents.yaml"}) as rec: + assert rec.run_id.startswith("run-") + assert rec.path == traces_dir / f"{rec.run_id}.jsonl" + emitter = get_context_emitter() + assert emitter.enabled and emitter.session_id == rec.run_id + emitter.agent_start("A") + emitter.agent_end("A") + assert not get_context_emitter().enabled # reset afterwards + + events = _lines(rec.path) + assert [e["event_type"] for e in events] == ["session_start", "agent_start", "agent_end", "session_end"] + assert events[0]["data"] == {"yaml_path": "/x/agents.yaml", "run_id": rec.run_id} + assert events[-1]["data"]["status"] == "completed" + + +def test_record_explicit_run_id(traces_dir): + from praisonai.replay import record + + with record("my-run") as rec: + pass + assert rec.run_id == "my-run" and rec.path.name == "my-run.jsonl" + + +def test_record_live_flushes_every_event(traces_dir): + from praisonai.replay import record + + with record(live=True) as rec: + assert len(_lines(rec.path)) == 1 # session_start already on disk + rec.emitter.agent_start("A") + assert len(_lines(rec.path)) == 2 + + +def test_record_batched_by_default(traces_dir): + from praisonai.replay import record + + with record() as rec: + rec.emitter.agent_start("A") + assert not rec.path.exists() or _lines(rec.path) == [] + assert len(_lines(rec.path)) == 3 + + +def test_record_marks_failure_and_resets_on_exception(traces_dir): + from praisonaiagents.trace.context_events import get_context_emitter + from praisonai.replay import record + + with pytest.raises(ValueError): + with record() as rec: + raise ValueError("boom") + assert _lines(rec.path)[-1]["data"]["status"] == "failed" + assert not get_context_emitter().enabled + + +def test_record_redacts_by_default(traces_dir): + from praisonai.replay import record + + with record() as rec: + rec.emitter.tool_call_start("A", "search", {"q": "x", "api_key": "sk-secret-value-123456"}) + text = rec.path.read_text() + assert "sk-secret-value-123456" not in text and "[REDACTED]" in text + + +def test_record_forked_from_is_stamped_on_workflow_start(traces_dir): + from praisonaiagents.trace.context_events import ContextEvent + from praisonai.replay.recorder import record + + with record(_forked_from={"run_id": "run-a", "node_id": "task:write"}) as rec: + ev = ContextEvent.from_dict({"event_type": "session_start", "session_id": rec.run_id}) + ev.event_type = "workflow_start" # what a 1.1 emitter emits + ev.data = {"kind": "team"} + rec.emitter._emit(ev) + events = _lines(rec.path) + assert events[0]["data"]["forked_from"] == {"run_id": "run-a", "node_id": "task:write"} + assert events[1]["data"] == {"kind": "team", "forked_from": {"run_id": "run-a", "node_id": "task:write"}} + + +# --------------------------------------------------------------------------- +# The three callers that used to duplicate the setup still record +# --------------------------------------------------------------------------- + +def test_recipe_save_replay_closes_trace_on_early_failure(traces_dir, monkeypatch): + """A recipe that is not found used to leave the trace open and the emitter set.""" + from praisonaiagents.trace.context_events import get_context_emitter + from praisonai.recipe import core + + monkeypatch.setattr(core, "_load_recipe", lambda name, offline=False: None) + result = core._run_impl("nope", options={"save_replay": True, "trace_name": "recipe-run"}) + assert result.error and "not found" in result.error + assert not get_context_emitter().enabled + events = _lines(traces_dir / "recipe-run.jsonl") + assert events[0]["event_type"] == "session_start" + assert events[0]["data"] == {"recipe": "nope", "run_id": "recipe-run"} + assert events[-1]["event_type"] == "session_end" + assert events[-1]["data"]["status"] == "failed" + + +def test_recipe_save_replay_success_passes_emitter(traces_dir, monkeypatch): + from types import SimpleNamespace + + from praisonaiagents.trace.context_events import get_context_emitter + from praisonai.recipe import core + + cfg = SimpleNamespace(name="r", version="1.0", defaults={}) + seen = {} + + def fake_execute(recipe_config, merged, session_id, options, trace_emitter=None): + seen["emitter"] = trace_emitter + seen["active"] = get_context_emitter() + trace_emitter.agent_start("RecipeAgent") + return {"ok": True} + + monkeypatch.setattr(core, "_load_recipe", lambda name, offline=False: cfg) + monkeypatch.setattr(core, "_check_dependencies", lambda c: {"all_satisfied": True}) + monkeypatch.setattr(core, "_check_tool_policy", lambda c, o: None) + monkeypatch.setattr(core, "_execute_recipe", fake_execute) + result = core._run_impl("r", options={"save_replay": True, "trace_name": "recipe-ok"}) + assert result.output == {"ok": True} + assert seen["emitter"] is seen["active"] and seen["emitter"].enabled + events = _lines(traces_dir / "recipe-ok.jsonl") + assert [e["event_type"] for e in events] == ["session_start", "agent_start", "session_end"] + assert events[-1]["data"]["status"] == "completed" + assert not get_context_emitter().enabled + + +class _FakeWorkflow: + name = "wf" + steps = [] + planning = False + reasoning = False + framework = "praisonai" + default_input = "hello" + verbose = False + + def __init__(self): + self.variables = {} + self.seen_emitter = None + + def start(self, _input): + from praisonaiagents.trace.context_events import get_context_emitter + + self.seen_emitter = get_context_emitter() + self.seen_emitter.agent_start("Step") + return {"status": "completed", "output": "done"} + + +def _run_workflow_command(monkeypatch, yaml_file, action_args): + from praisonaiagents.workflows import WorkflowManager + from praisonai.cli.legacy import workflow_commands + + wf = _FakeWorkflow() + monkeypatch.setattr(WorkflowManager, "load_yaml", lambda self, path, tool_registry=None: wf) + code = workflow_commands._run_yaml_workflow(None, str(yaml_file), action_args) + return code, wf + + +def test_workflow_run_save_records(traces_dir, tmp_path, monkeypatch): + from praisonaiagents.trace.context_events import get_context_emitter + + y = tmp_path / "wf.yaml" + y.write_text("name: wf\nsteps: []\n") + code, wf = _run_workflow_command(monkeypatch, y, ["--save"]) + assert code == 0 + assert wf.seen_emitter.enabled + assert not get_context_emitter().enabled + events = _lines(traces_dir / f"{wf.seen_emitter.session_id}.jsonl") + assert events[0]["data"]["workflow"] == str(y) + assert events[0]["data"]["yaml_path"] == str(y.resolve()) + assert [e["event_type"] for e in events] == ["session_start", "agent_start", "session_end"] + + +def test_workflow_run_yaml_save_replay_key_records(traces_dir, tmp_path, monkeypatch): + y = tmp_path / "wf.yaml" + y.write_text("name: wf\nsave_replay: true\nsteps: []\n") + code, wf = _run_workflow_command(monkeypatch, y, []) + assert code == 0 and wf.seen_emitter.enabled + assert (traces_dir / f"{wf.seen_emitter.session_id}.jsonl").exists() + + +def test_workflow_run_without_save_does_not_record(traces_dir, tmp_path, monkeypatch): + y = tmp_path / "wf.yaml" + y.write_text("name: wf\nsteps: []\n") + code, wf = _run_workflow_command(monkeypatch, y, []) + assert code == 0 and not wf.seen_emitter.enabled + assert not traces_dir.exists() or not list(traces_dir.iterdir()) + + +def _drive_legacy_main(monkeypatch, tmp_path, argv, yaml_text): + import sys + + pa = pytest.importorskip("praisonai_code.cli.legacy.praison_ai") + from praisonaiagents.trace.context_events import get_context_emitter + + seen = {} + + class FakeGenerator: + def __init__(self, *a, **k): + pass + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def generate_crew_and_kickoff(self): + seen["emitter"] = get_context_emitter() + return "RESULT" + + (tmp_path / "agents.yaml").write_text(yaml_text) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("OPENAI_API_KEY", "sk-test-not-used") + monkeypatch.setattr(pa, "_get_agents_generator", lambda: FakeGenerator) + monkeypatch.setattr(sys, "argv", argv) + monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) + result = pa.PraisonAI(agent_file="agents.yaml").main() + return result, seen.get("emitter") + + +_AGENTS_YAML = ( + "framework: praisonai\nroles:\n a:\n role: A\n goal: g\n backstory: b\n" + " tasks:\n t:\n description: d\n expected_output: e\n" +) + + +def test_agents_yaml_save_flag_records(traces_dir, tmp_path, monkeypatch): + from praisonaiagents.trace.context_events import get_context_emitter + + result, emitter = _drive_legacy_main(monkeypatch, tmp_path, ["praisonai", "agents.yaml", "--save"], _AGENTS_YAML) + assert result == "RESULT" + assert emitter is not None and emitter.enabled + assert not get_context_emitter().enabled + events = _lines(traces_dir / f"{emitter.session_id}.jsonl") + assert events[0]["data"]["agents_file"] == "agents.yaml" + assert events[0]["data"]["yaml_path"] == str((tmp_path / "agents.yaml").resolve()) + assert events[-1]["event_type"] == "session_end" + + +def test_agents_yaml_save_replay_key_records(traces_dir, tmp_path, monkeypatch): + result, emitter = _drive_legacy_main( + monkeypatch, tmp_path, ["praisonai", "agents.yaml"], "save_replay: true\n" + _AGENTS_YAML + ) + assert result == "RESULT" and emitter.enabled + assert (traces_dir / f"{emitter.session_id}.jsonl").exists() + + +def test_agents_yaml_without_save_is_untraced(traces_dir, tmp_path, monkeypatch): + result, emitter = _drive_legacy_main(monkeypatch, tmp_path, ["praisonai", "agents.yaml"], _AGENTS_YAML) + assert result == "RESULT" and not emitter.enabled + + +def test_save_replay_is_a_known_yaml_field(): + from praisonai.config.schema import YAMLConfig + + assert "save_replay" in YAMLConfig.model_fields From cf241acdf12d015048fbd77c7ef50416ae8fd0bc Mon Sep 17 00:00:00 2001 From: MervinPraison <454862+MervinPraison@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:32:00 +0000 Subject: [PATCH 02/12] feat(replay): export runs to JSON, Mermaid, OTLP-JSON and one HTML file praisonai.replay.export, stdlib only: - to_json: {schema, run_id, graph, events}. - to_mermaid: sequenceDiagram with lanes You / Manager / one per agent; messages are task boundaries, hand-offs/delegations and approvals (1.0 traces fall back to agent start/end). - to_otlp_json: offline OTLP/JSON with OTel GenAI semconv (invoke_workflow / invoke_agent / chat / execute_tool, INTERNAL task/step/approval spans), traceId = sha256(run_id)[:32], spanId = sha256(run_id:start_seq)[:16], parents from parent_seq or nesting; scope marked gen_ai.semconv.stability=development. - to_html: self-contained page, no CDN. Inlines aiui's standalone replay-core.js + replay.js when aiui ships them (detected at runtime), always includes a plain table timeline; embedded JSON escapes every "<" so event data cannot close the script. - Everything is redacted by default (praisonaiagents.trace.redact key rules + scrub_pii_text), keeping numeric token counters; redact=False opts out. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/praisonai/praisonai/replay/export.py | 575 ++++++++++++++++++ .../tests/unit/replay/test_replay_export.py | 244 ++++++++ 2 files changed, 819 insertions(+) create mode 100644 src/praisonai/praisonai/replay/export.py create mode 100644 src/praisonai/tests/unit/replay/test_replay_export.py diff --git a/src/praisonai/praisonai/replay/export.py b/src/praisonai/praisonai/replay/export.py new file mode 100644 index 0000000000..8a6a03a6d5 --- /dev/null +++ b/src/praisonai/praisonai/replay/export.py @@ -0,0 +1,575 @@ +"""Export a replay trace: JSON, Mermaid, OTLP-JSON (OTel GenAI) or one HTML file. + + from praisonai.replay.events import load_events + from praisonai.replay import export + + events = load_events("run-1a2b3c4d5e6f") + html = export.to_html(events) # self-contained, no CDN + otlp = export.to_otlp_json(events) # dict, OTLP/JSON trace payload + +Every exporter redacts by default (secrets by key name, plus secret/PII +patterns in free text) using the helpers in ``praisonaiagents.trace.redact``. +Pass ``redact=False`` to export the trace as recorded. + +Stdlib only: no OpenTelemetry dependency, nothing fetched at view time. +""" + +from __future__ import annotations + +import copy +import hashlib +import html as _html +import json +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple + +from .events import as_dicts, etype, find_graph, pair_spans, run_id_of + +__all__ = [ + "redact_events", + "to_json", + "to_mermaid", + "to_otlp_json", + "to_html", + "aiui_renderer_files", + "SEMCONV_STABILITY", +] + +#: The OTel GenAI semantic conventions are still "Development" status. +SEMCONV_STABILITY = "development" +_REDACTED = "[REDACTED]" + + +# --------------------------------------------------------------------------- +# Redaction +# --------------------------------------------------------------------------- + +def _redactors(): + """Key test + free-text scrubber, reused from ``praisonaiagents.trace.redact``.""" + from praisonaiagents.trace import redact as _r + + should_redact = getattr(_r, "_should_redact", None) + if should_redact is None: # pragma: no cover - older praisonaiagents + def should_redact(key: str) -> bool: + return _r.redact_dict({key: "x"}).get(key) == _r.REDACTED_VALUE + return should_redact, _r.scrub_pii_text + + +def _redact_value(value: Any, should_redact, scrub) -> Any: + if isinstance(value, str): + return scrub(value) + if isinstance(value, dict): + out = {} + for key, item in value.items(): + # Numbers/bools under a "token"-ish key are counters, not secrets + # (``prompt_tokens``, ``max_tokens``); keep them readable. + if isinstance(key, str) and should_redact(key) and not isinstance(item, (int, float, bool, type(None))): + out[key] = _REDACTED + else: + out[key] = _redact_value(item, should_redact, scrub) + return out + if isinstance(value, list): + return [_redact_value(item, should_redact, scrub) for item in value] + return value + + +def redact_events(events: Iterable[Any]) -> List[Dict[str, Any]]: + """Copies of ``events`` with secrets removed from ``data`` (envelope kept).""" + should_redact, scrub = _redactors() + out = [] + for ev in as_dicts(events): + ev = copy.deepcopy(ev) + if isinstance(ev.get("data"), dict): + ev["data"] = _redact_value(ev["data"], should_redact, scrub) + out.append(ev) + return out + + +def _prepare(events: Iterable[Any], redact: bool) -> List[Dict[str, Any]]: + return redact_events(events) if redact else as_dicts(events) + + +# --------------------------------------------------------------------------- +# JSON +# --------------------------------------------------------------------------- + +def to_json(events: Iterable[Any], *, redact: bool = True, indent: Optional[int] = 2) -> str: + """``{"run_id", "events", "graph"}`` as a JSON string.""" + evs = _prepare(events, redact) + payload = { + "schema": "praisonai.replay/1", + "run_id": run_id_of(evs), + "graph": find_graph(evs), + "events": evs, + } + return json.dumps(payload, indent=indent, default=str) + + +# --------------------------------------------------------------------------- +# Mermaid sequence diagram +# --------------------------------------------------------------------------- + +_YOU = "You" +_MANAGER = "Manager" + + +def _mm_text(text: Any, limit: int = 60) -> str: + """Mermaid-safe message text (``;`` ends a statement, ``#`` starts an entity).""" + s = " ".join(str(text or "").split()) + s = s.replace(";", ",").replace("#", "no.").replace("%%", "%") + if len(s) > limit: + s = s[: limit - 1] + "โ€ฆ" + return s + + +def to_mermaid(events: Iterable[Any], *, redact: bool = True) -> str: + """A Mermaid ``sequenceDiagram`` of who handed what to whom. + + Lanes are "You", "Manager" (when a hierarchical manager appears) and one per + agent in order of first appearance. Messages are task boundaries, + hand-offs/delegations and approvals. A 1.0 trace without task events falls + back to agent start/end. + """ + evs = _prepare(events, redact) + has_tasks = any(etype(e) == "task_start" for e in evs) + + agents: List[str] = [] + manager = False + + def lane(name: Optional[str]) -> str: + nonlocal manager + if not name: + return _YOU + if name == _MANAGER: + manager = True + return _MANAGER + if name not in agents: + agents.append(name) + return name + + messages: List[Tuple[str, str, str, str]] = [] # (src, arrow, dst, text) + open_agents: Dict[str, int] = {} + task_starts: Dict[Any, Dict[str, Any]] = {} # seq / node_id -> task_start event + for ev in evs: + kind = etype(ev) + data = ev.get("data") or {} + who = ev.get("agent_name") + if kind == "task_start": + task_starts[ev.get("sequence_num")] = ev + if ev.get("node_id"): + task_starts[ev["node_id"]] = ev + dst = lane(data.get("agent") or who) + messages.append((_YOU, "->>", dst, f"task {data.get('name') or ev.get('node_id') or ''}")) + elif kind == "task_end": + # task_end carries only the outcome; name/agent live on its start. + start = task_starts.get(ev.get("parent_seq")) or task_starts.get(ev.get("node_id")) or {} + s_data = start.get("data") or {} + src = lane(data.get("agent") or s_data.get("agent") or who or start.get("agent_name")) + name = data.get("name") or s_data.get("name") or ev.get("node_id") or "" + messages.append((src, "-->>", _YOU, f"task {name} {data.get('status') or 'done'}")) + elif kind == "agent_handoff": + src = lane(data.get("from_agent") or who) + dst = lane(data.get("to_agent")) + label = data.get("kind") or "handoff" + reason = data.get("reason") + messages.append((src, "->>", dst, f"{label}: {reason}" if reason else label)) + elif kind == "approval_requested": + src = lane(who) + messages.append((src, "->>", _YOU, f"approve {data.get('tool') or 'action'}?")) + elif kind == "approval_decided": + dst = lane(who) + verdict = "approved" if data.get("approved") else "denied" + messages.append((_YOU, "-->>", dst, verdict)) + elif kind in ("agent_start", "agent_end") and who: + name = lane(who) + if has_tasks: + continue + if kind == "agent_start": + open_agents[name] = open_agents.get(name, 0) + 1 + if open_agents[name] == 1: + messages.append((_YOU, "->>", name, "start")) + else: + open_agents[name] = max(0, open_agents.get(name, 0) - 1) + if open_agents[name] == 0: + messages.append((name, "-->>", _YOU, "done")) + + ids: Dict[str, str] = {_YOU: _YOU, _MANAGER: _MANAGER} + lines = ["sequenceDiagram", f" participant {_YOU}"] + if manager: + lines.append(f" participant {_MANAGER}") + for i, name in enumerate(agents): + ids[name] = f"A{i}" + lines.append(f" participant A{i} as {_mm_text(name, 40)}") + for src, arrow, dst, text in messages: + lines.append(f" {ids[src]}{arrow}{ids[dst]}: {_mm_text(text)}") + return "\n".join(lines) + "\n" + + +# --------------------------------------------------------------------------- +# OTLP-JSON (OpenTelemetry GenAI semantic conventions) +# --------------------------------------------------------------------------- + +_KIND_INTERNAL = 1 +_KIND_CLIENT = 3 +_STATUS_UNSET, _STATUS_OK, _STATUS_ERROR = 0, 1, 2 + + +def _trace_id(run_id: str) -> str: + return hashlib.sha256(run_id.encode("utf-8")).hexdigest()[:32] + + +def _span_id(run_id: str, start_seq: int) -> str: + return hashlib.sha256(f"{run_id}:{start_seq}".encode("utf-8")).hexdigest()[:16] + + +def _attr(key: str, value: Any) -> Optional[Dict[str, Any]]: + if value is None or value == "": + return None + if isinstance(value, bool): + return {"key": key, "value": {"boolValue": value}} + if isinstance(value, int): + return {"key": key, "value": {"intValue": str(value)}} + if isinstance(value, float): + return {"key": key, "value": {"doubleValue": value}} + if isinstance(value, (list, tuple)) and all(isinstance(v, str) for v in value): + return {"key": key, "value": {"arrayValue": {"values": [{"stringValue": v} for v in value]}}} + if not isinstance(value, str): + value = json.dumps(value, default=str) + return {"key": key, "value": {"stringValue": value}} + + +def _nanos(ts: Any) -> str: + try: + return str(int(float(ts) * 1_000_000_000)) + except (TypeError, ValueError): + return "0" + + +def _span_shape(span: Dict[str, Any]) -> Tuple[str, int, List[Tuple[str, Any]]]: + """(name, kind, attributes) for one start/end pair.""" + start, end = span["start"], span["end"] or {} + s_data = start.get("data") or {} + e_data = end.get("data") or {} + agent = start.get("agent_name") + kind = span["type"] + attrs: List[Tuple[str, Any]] = [ + ("gen_ai.conversation.id", start.get("session_id")), + ("praisonai.node_id", start.get("node_id")), + ("praisonai.event_type", kind), + ] + if kind == "workflow_start": + name = s_data.get("name") or "workflow" + attrs += [ + ("gen_ai.operation.name", "invoke_workflow"), + ("gen_ai.workflow.name", name), + ("praisonai.workflow.kind", s_data.get("kind")), + ("praisonai.workflow.process", s_data.get("process")), + ("praisonai.forked_from", s_data.get("forked_from")), + ] + return f"invoke_workflow {name}", _KIND_INTERNAL, attrs + if kind == "agent_start": + attrs += [("gen_ai.operation.name", "invoke_agent"), ("gen_ai.agent.name", agent)] + return f"invoke_agent {agent or ''}".strip(), _KIND_INTERNAL, attrs + if kind == "llm_request": + model = e_data.get("model") or s_data.get("model") + attrs += [ + ("gen_ai.operation.name", "chat"), + ("gen_ai.agent.name", agent), + ("gen_ai.request.model", model), + ("gen_ai.response.model", e_data.get("model")), + ("gen_ai.usage.input_tokens", end.get("prompt_tokens") or None), + ("gen_ai.usage.output_tokens", end.get("completion_tokens") or None), + ("gen_ai.response.finish_reasons", [e_data["finish_reason"]] if e_data.get("finish_reason") else None), + ("praisonai.cost_usd", end.get("cost_usd") or None), + ] + return f"chat {model or ''}".strip(), _KIND_CLIENT, attrs + if kind == "tool_call_start": + tool = s_data.get("tool_name") or e_data.get("tool_name") + attrs += [ + ("gen_ai.operation.name", "execute_tool"), + ("gen_ai.tool.name", tool), + ("gen_ai.agent.name", agent), + ("gen_ai.tool.call.arguments", s_data.get("tool_args") or None), + ] + return f"execute_tool {tool or ''}".strip(), _KIND_INTERNAL, attrs + if kind == "task_start": + attrs += [ + ("praisonai.task.name", s_data.get("name")), + ("gen_ai.agent.name", s_data.get("agent") or agent), + ("praisonai.task.status", e_data.get("status")), + ("praisonai.task.retries", e_data.get("retries")), + ] + return f"task {s_data.get('name') or ''}".strip(), _KIND_INTERNAL, attrs + if kind == "step_start": + attrs += [ + ("praisonai.step.name", s_data.get("name")), + ("praisonai.step.pattern", s_data.get("pattern")), + ("praisonai.step.status", e_data.get("status")), + ("praisonai.step.route", e_data.get("route")), + ] + return f"step {s_data.get('name') or ''}".strip(), _KIND_INTERNAL, attrs + if kind == "approval_requested": + attrs += [ + ("praisonai.approval.id", s_data.get("approval_id")), + ("praisonai.approval.tool", s_data.get("tool")), + ("praisonai.approval.risk", s_data.get("risk")), + ("praisonai.approval.approved", e_data.get("approved")), + ("praisonai.approval.approver", e_data.get("approver")), + ] + return f"approval {s_data.get('tool') or ''}".strip(), _KIND_INTERNAL, attrs + # session_start / anything else + return "praisonai.session", _KIND_INTERNAL, attrs + + +def _span_status(span: Dict[str, Any]) -> Dict[str, Any]: + end = span["end"] + if end is None: + return {"code": _STATUS_UNSET} + data = end.get("data") or {} + failed = data.get("status") == "failed" or bool(data.get("error")) + if span["type"] == "approval_requested" and data.get("approved") is False: + return {"code": _STATUS_OK, "message": "denied"} + if failed: + return {"code": _STATUS_ERROR, "message": str(data.get("error") or "failed")[:200]} + return {"code": _STATUS_OK} + + +def to_otlp_json(events: Iterable[Any], *, redact: bool = True, service_name: str = "praisonai") -> Dict[str, Any]: + """An OTLP/JSON ``ExportTraceServiceRequest`` for the trace. + + Ids are deterministic so re-exporting a run gives the same trace: + ``traceId = sha256(run_id)[:32]`` and ``spanId = sha256(f"{run_id}:{start_seq}")[:16]``; + ``parentSpanId`` comes from ``parent_seq`` (schema 1.1) or nesting (1.0). + Attributes follow the OTel GenAI semantic conventions, which are still in + *Development* status (recorded as ``gen_ai.semconv.stability`` on the scope). + """ + evs = _prepare(events, redact) + run_id = run_id_of(evs) + trace_id = _trace_id(run_id) + spans, _points = pair_spans(evs) + last_ts = max((e.get("timestamp") or 0 for e in evs), default=0) + known = {span["seq"] for span in spans} + + out_spans = [] + for span in spans: + name, kind, attrs = _span_shape(span) + start, end = span["start"], span["end"] + record: Dict[str, Any] = { + "traceId": trace_id, + "spanId": _span_id(run_id, span["seq"]), + "name": name, + "kind": kind, + "startTimeUnixNano": _nanos(start.get("timestamp")), + "endTimeUnixNano": _nanos((end or {}).get("timestamp", last_ts)), + "attributes": [a for a in (_attr(k, v) for k, v in attrs) if a], + "status": _span_status(span), + } + if span["parent"] is not None and span["parent"] in known: + record["parentSpanId"] = _span_id(run_id, span["parent"]) + out_spans.append(record) + + try: + from praisonai.version import __version__ as version + except Exception: # pragma: no cover - version module is always present in the wrapper + version = "" + + return { + "resourceSpans": [{ + "resource": {"attributes": [ + _attr("service.name", service_name), + _attr("praisonai.run_id", run_id), + ]}, + "scopeSpans": [{ + "scope": { + "name": "praisonai.replay", + "version": version, + "attributes": [_attr("gen_ai.semconv.stability", SEMCONV_STABILITY)], + }, + "spans": out_spans, + }], + }] + } + + +# --------------------------------------------------------------------------- +# HTML (self-contained) +# --------------------------------------------------------------------------- + +_RENDERER_FILES = ("replay-core.js", "replay.js") + + +def aiui_renderer_files() -> Optional[List[Path]]: + """The aiui standalone replay renderer, if aiui is installed and ships it.""" + import importlib.util + + try: + spec = importlib.util.find_spec("praisonaiui") + except (ImportError, ValueError): + return None + if spec is None or not spec.submodule_search_locations: + return None + for root in spec.submodule_search_locations: + views = Path(root) / "templates" / "frontend" / "plugins" / "views" + files = [views / name for name in _RENDERER_FILES] + if all(f.is_file() for f in files): + return files + return None + + +def _script_safe(text: str) -> str: + """Make text safe inside an inline ``", html, re.S).group(1)) + assert data["run_id"] == "run-team01" + assert data["graph"]["schema"] == "praisonai.graph/1" + assert len(data["events"]) == len(fx.team_events()) + + +def test_html_no_redact_keeps_content(fx, monkeypatch): + from praisonai.replay import export + + monkeypatch.setattr(export, "aiui_renderer_files", lambda: None) + assert "bob@example.com" in export.to_html(fx.team_events(), redact=False) + + +def test_html_inlines_aiui_renderer_when_available(fx, monkeypatch, tmp_path): + from praisonai.replay import export + + core = tmp_path / "replay-core.js" + view = tmp_path / "replay.js" + core.write_text("window.ReplayCore = {}; // must not end the tag") + view.write_text("document.getElementById('replay-root').dataset.mounted = '1';") + monkeypatch.setattr(export, "aiui_renderer_files", lambda: [core, view]) + html = export.to_html(fx.team_events()) + assert "window.ReplayCore = {};" in html and "dataset.mounted" in html + assert html.index("window.__REPLAY_DATA__") < html.index("window.ReplayCore") + assert "// " not in html # escaped so the inline script survives + assert not _EXTERNAL_SRC.search(html) + + +def test_html_escapes_script_breakers_in_event_data(fx, monkeypatch): + from praisonai.replay import export + + monkeypatch.setattr(export, "aiui_renderer_files", lambda: None) + evs = fx.team_events() + evs[9]["data"]["output_preview"] = "" + html = export.to_html(evs, redact=False) + # Only "). + assert "") == html.count(" Date: Mon, 26 Oct 2026 11:32:19 +0000 Subject: [PATCH 03/12] feat(replay): replay view / export CLI, mermaid flow and YAML static graph - replay flow --format ascii|json|mermaid: a YAML file gives the static graph (diagram.to_graph / graph_to_mermaid when the SDK has them, else AgentFlow.to_mermaid()); a run gives the sequence diagram. Existing ascii/json output for runs is unchanged. - replay view [RUN|JSONL|YAML] [--live] [--port 8090] [--host 127.0.0.1] launches aiui with the new praisonai/ui_replay preset (pages=["replay"]) and opens http://127.0.0.1:/#/replay?run=. A YAML file is run recorded live in a background thread while serving; a JSONL path serves its own directory (PRAISONAI_TRACES_DIR for the server). Warns on a non-loopback host, fails with the pip install "praisonai[ui]" hint when aiui is missing, and stops the server on exit/SIGTERM. - replay export RUN --format html|json|mermaid|otel [-o FILE] [--no-redact]. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../praisonai/cli/commands/replay.py | 321 +++++++++++++++++- src/praisonai/praisonai/ui_replay/__init__.py | 1 + .../praisonai/ui_replay/default_app.py | 23 ++ .../tests/unit/replay/test_replay_cli.py | 302 ++++++++++++++++ 4 files changed, 644 insertions(+), 3 deletions(-) create mode 100644 src/praisonai/praisonai/ui_replay/__init__.py create mode 100644 src/praisonai/praisonai/ui_replay/default_app.py create mode 100644 src/praisonai/tests/unit/replay/test_replay_cli.py diff --git a/src/praisonai/praisonai/cli/commands/replay.py b/src/praisonai/praisonai/cli/commands/replay.py index f3bd80f209..99f80fcb07 100644 --- a/src/praisonai/praisonai/cli/commands/replay.py +++ b/src/praisonai/praisonai/cli/commands/replay.py @@ -4,6 +4,9 @@ Provides context replay functionality: - replay list: List available traces - 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 export --format html|json|mermaid|otel: Export a run """ from typing import Optional @@ -465,19 +468,42 @@ def replay_delete( def replay_flow( session_id: str = typer.Argument( ..., - help="Session ID or path to trace file", + help="Session ID, path to trace file, or a YAML definition (static graph)", ), format: str = typer.Option( "ascii", "--format", "-f", - help="Output format: ascii, json", + help="Output format: ascii, json, mermaid", ), ): - """Visualize agent flow from a context trace.""" + """Visualize agent flow from a context trace, or a YAML definition before it runs.""" from ..output.console import get_output_controller output = get_output_controller() + + if format not in ("ascii", "json", "mermaid"): + output.print_error(f"Unknown format: {format} (use ascii, json or mermaid)") + raise typer.Exit(2) + + from praisonai.replay.yaml_run import is_yaml_path + + if is_yaml_path(session_id): + _print_static_flow(output, session_id, format) + return + + if format == "mermaid": + from praisonai.replay.events import load_events + from praisonai.replay.export import to_mermaid + + try: + raw_events = load_events(session_id) + except FileNotFoundError: + output.print_error(f"Trace not found: {session_id}") + raise typer.Exit(1) + # Plain echo: Rich would treat mermaid's [brackets] as markup. + typer.echo(to_mermaid(raw_events), nl=False) + return try: from praisonai.replay import ContextTraceReader @@ -507,6 +533,33 @@ def replay_flow( _print_ascii_flow(output, flow_data, session_id) +def _print_static_flow(output, yaml_file: str, format: str) -> None: + """Static graph of a YAML definition (nothing is executed).""" + from praisonai.replay.yaml_run import static_graph, static_mermaid + + try: + if format == "mermaid": + typer.echo(static_mermaid(yaml_file), nl=False) + return + graph, _flow = static_graph(yaml_file) + except Exception as e: + output.print_error(f"Could not read {yaml_file}: {e}") + raise typer.Exit(1) + + if format == "json": + output.print_json(graph) + return + + typer.echo(f"\n STATIC GRAPH: {yaml_file} ({graph.get('kind')}, {graph.get('process')})\n") + for node in graph.get("nodes", []): + typer.echo(f" [{node.get('kind')}] {node.get('label')} ({node.get('id')})") + typer.echo("") + for edge in graph.get("edges", []): + label = f" [{edge['label']}]" if edge.get("label") else "" + typer.echo(f" {edge.get('source')} --{edge.get('kind')}--> {edge.get('target')}{label}") + typer.echo("") + + def _build_flow_from_events(events) -> dict: """Build flow structure from context events.""" agents = {} @@ -828,3 +881,265 @@ def replay_cleanup( output.print_success(f"Deleted {deleted} old traces.") else: output.print_info("No traces needed cleanup.") + + +# --------------------------------------------------------------------------- +# PraisonAI Replay: view / export +# --------------------------------------------------------------------------- + +_AIUI_INSTALL_HINT = 'pip install "praisonai[ui]"' +_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"} +_EXPORT_FORMATS = ("html", "json", "mermaid", "otel") + + +def _aiui_installed() -> bool: + import importlib.util + + try: + return importlib.util.find_spec("praisonaiui") is not None + except (ImportError, ValueError): + return False + + +def _replay_app_path(): + """The bundled ``praisonai/ui_replay/default_app.py`` preset.""" + from pathlib import Path + + import praisonai + + return Path(praisonai.__file__).resolve().parent / "ui_replay" / "default_app.py" + + +def _spawn_aiui(app_path, host: str, port: int, env: dict): + """Start ``aiui run`` for the replay app (falls back to ``python -m praisonaiui.cli``).""" + import shutil + import subprocess + import sys + + args = ["run", str(app_path), "--port", str(port), "--host", host] + exe = shutil.which("aiui") + cmd = [exe, *args] if exe else [sys.executable, "-m", "praisonaiui.cli", *args] + return subprocess.Popen(cmd, env=env) + + +def _open_browser(url: str, host: str, port: int, timeout: float = 20.0) -> None: + """Open ``url`` once the server accepts connections (in a daemon thread).""" + import socket + import threading + import time + import webbrowser + + probe_host = "127.0.0.1" if host in ("0.0.0.0", "::", "") else host + + def _wait_then_open(): + deadline = time.time() + timeout + while time.time() < deadline: + try: + with socket.create_connection((probe_host, port), timeout=0.5): + break + except OSError: + time.sleep(0.25) + webbrowser.open(url) + + threading.Thread(target=_wait_then_open, name="replay-open-browser", daemon=True).start() + + +def _replay_url(host: str, port: int, run_id: Optional[str], live: bool) -> str: + url_host = "127.0.0.1" if host in ("0.0.0.0", "::", "") else host + if ":" in url_host and not url_host.startswith("["): + url_host = f"[{url_host}]" + url = f"http://{url_host}:{port}/#/replay" + params = [] + if run_id: + params.append(f"run={run_id}") + if live: + params.append("live=1") + return url + ("?" + "&".join(params) if params else "") + + +def _start_yaml_run(yaml_file: str, run_id: str): + """Run a YAML definition recorded live in a background thread.""" + import threading + + from praisonai.replay.yaml_run import run_yaml_recorded + + def _target(): + try: + run_yaml_recorded(yaml_file, run_id=run_id, live=True) + typer.echo(f"Run finished: {run_id}") + except Exception as e: # the viewer keeps serving the partial trace + typer.echo(f"Run failed: {e}", err=True) + + thread = threading.Thread(target=_target, name="replay-yaml-run", daemon=True) + thread.start() + return thread + + +@app.command("view") +def replay_view( + target: Optional[str] = typer.Argument( + None, + help="Run id, trace .jsonl path, or a YAML file to run and watch (default: recent runs)", + ), + live: bool = typer.Option( + False, + "--live", + help="Follow the run as it is written (implied for a YAML file)", + ), + port: int = typer.Option(8090, "--port", "-p", help="Port for the replay UI"), + host: str = typer.Option("127.0.0.1", "--host", help="Host to bind to"), +): + """Open PraisonAI Replay (timeline + map) for a run in the browser. + + Examples: + praisonai replay view + praisonai replay view run-1a2b3c4d5e6f + praisonai replay view agents.yaml # runs it, recorded, and follows it live + """ + import os + from pathlib import Path + + from praisonai.replay.events import SAFE_RUN_ID, resolve_trace_path + from praisonai.replay.recorder import new_run_id + from praisonai.replay.storage import resolve_traces_dir + from praisonai.replay.yaml_run import is_yaml_path + + if not _aiui_installed(): + typer.echo("ERROR: PraisonAI Replay needs PraisonAI UI (aiui), which is not installed.", err=True) + typer.echo(f"Install with:\n {_AIUI_INSTALL_HINT}", err=True) + raise typer.Exit(1) + + if host not in _LOOPBACK_HOSTS: + typer.echo( + f"WARNING: serving replay on {host} makes your traces (prompts, outputs, tool " + "arguments) reachable from the network. Use --host 127.0.0.1 to keep them local.", + err=True, + ) + + traces_dir = resolve_traces_dir() + run_id: Optional[str] = None + yaml_file: Optional[str] = None + + if target and is_yaml_path(target): + yaml_file = target + run_id = new_run_id() + live = True + elif target: + path = resolve_trace_path(target) + if str(target).endswith(".jsonl") or "/" in str(target): + traces_dir = path.parent + run_id = path.stem + if not SAFE_RUN_ID.match(run_id): + typer.echo(f"ERROR: not a valid run id: {run_id}", err=True) + raise typer.Exit(2) + if not path.exists() and not live: + typer.echo(f"ERROR: Trace not found: {target}", err=True) + typer.echo("Use 'praisonai replay list' to see available traces (or --live to wait for it).", err=True) + raise typer.Exit(1) + + app_path = _replay_app_path() + if not app_path.exists(): + typer.echo(f"ERROR: bundled replay app missing: {app_path}", err=True) + raise typer.Exit(1) + + env = os.environ.copy() + env["PRAISONAI_TRACES_DIR"] = str(Path(traces_dir)) + url = _replay_url(host, port, run_id, live) + + if yaml_file: + typer.echo(f"Running {yaml_file} as {run_id} (recording live to {Path(traces_dir) / (run_id + '.jsonl')})") + _start_yaml_run(yaml_file, run_id) + + typer.echo(f"PraisonAI Replay: {url}") + proc = _spawn_aiui(app_path, host, port, env) + _open_browser(url, host, port) + code = _wait_for_server(proc) + if code: + raise typer.Exit(code) + + +def _wait_for_server(proc) -> int: + """Wait for the aiui server; never leave it running after this CLI exits.""" + import signal + import threading + + previous = None + if threading.current_thread() is threading.main_thread(): + def _on_term(signum, _frame): + raise SystemExit(128 + signum) + + previous = signal.signal(signal.SIGTERM, _on_term) + try: + return proc.wait() + except KeyboardInterrupt: + return 0 + finally: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except Exception: + proc.kill() + if previous is not None: + signal.signal(signal.SIGTERM, previous) + + +def _load_raw_events(output, run: str): + from praisonai.replay.events import load_events + + try: + events = load_events(run) + except FileNotFoundError: + output.print_error(f"Trace not found: {run}") + output.print_info("Use 'praisonai replay list' to see available traces.") + raise typer.Exit(1) + if not events: + output.print_error(f"No events in trace: {run}") + raise typer.Exit(1) + return events + + +@app.command("export") +def replay_export( + run: str = typer.Argument(..., help="Run id or path to trace file"), + format: str = typer.Option("html", "--format", "-f", help="html, json, mermaid or otel (OTLP-JSON)"), + output_file: Optional[str] = typer.Option( + None, "--output", "-o", help="Write to FILE (default: stdout; html: ./.html)" + ), + no_redact: bool = typer.Option( + False, "--no-redact", help="Export as recorded, without removing secrets/PII" + ), +): + """Export a run: a self-contained HTML replay, JSON, Mermaid or OTLP-JSON. + + Everything is redacted by default; --no-redact exports the trace as recorded. + """ + import json + from pathlib import Path + + from ..output.console import get_output_controller + from praisonai.replay import export as exporter + from praisonai.replay.events import run_id_of + + output = get_output_controller() + if format not in _EXPORT_FORMATS: + output.print_error(f"Unknown format: {format} (use {', '.join(_EXPORT_FORMATS)})") + raise typer.Exit(2) + + events = _load_raw_events(output, run) + redact = not no_redact + if format == "html": + text = exporter.to_html(events, redact=redact) + output_file = output_file or f"{run_id_of(events, Path(run).stem)}.html" + elif format == "json": + text = exporter.to_json(events, redact=redact) + elif format == "mermaid": + text = exporter.to_mermaid(events, redact=redact) + else: + text = json.dumps(exporter.to_otlp_json(events, redact=redact), indent=2) + + if output_file: + Path(output_file).write_text(text, encoding="utf-8") + typer.echo(f"Wrote {format} export to {output_file}") + else: + typer.echo(text, nl=not text.endswith("\n")) diff --git a/src/praisonai/praisonai/ui_replay/__init__.py b/src/praisonai/praisonai/ui_replay/__init__.py new file mode 100644 index 0000000000..8ef7e81b7a --- /dev/null +++ b/src/praisonai/praisonai/ui_replay/__init__.py @@ -0,0 +1 @@ +"""PraisonAI Replay โ€” bundled default app for ``praisonai replay view``.""" diff --git a/src/praisonai/praisonai/ui_replay/default_app.py b/src/praisonai/praisonai/ui_replay/default_app.py new file mode 100644 index 0000000000..946e28ac72 --- /dev/null +++ b/src/praisonai/praisonai/ui_replay/default_app.py @@ -0,0 +1,23 @@ +"""PraisonAI Replay โ€” aiui app that opens straight to the replay page. + +Loaded by ``praisonai replay view`` -> ``aiui run default_app.py``. The replay +page reads traces from ``PRAISONAI_TRACES_DIR`` (set by the CLI), else +``$PRAISON_HOME/traces``, else ``~/.praison/traces``. + +Requires: pip install "praisonai[ui]" + +Run: + praisonai replay view # recent runs + praisonai replay view RUN_ID # one run + praisonai replay view agents.yaml --live +""" + +from praisonai.integration.host_app import UIPreset, build_ui_app + +app = build_ui_app(UIPreset( + title="PraisonAI Replay", + pages=["replay"], + sidebar=False, + theme={"preset": "blue", "dark_mode": False, "radius": "lg"}, + welcome="PraisonAI Replay: pick a run to see what happened.", +)) diff --git a/src/praisonai/tests/unit/replay/test_replay_cli.py b/src/praisonai/tests/unit/replay/test_replay_cli.py new file mode 100644 index 0000000000..e89ad5d0e5 --- /dev/null +++ b/src/praisonai/tests/unit/replay/test_replay_cli.py @@ -0,0 +1,302 @@ +"""``praisonai replay`` CLI: flow (mermaid / YAML), export, view, and 1.0 traces still readable.""" + +import json +import textwrap + +import pytest +from typer.testing import CliRunner + + +def _invoke(*args): + from praisonai.cli.commands.replay import app + + return CliRunner().invoke(app, list(args)) + + +FLOW_YAML = textwrap.dedent("""\ + name: blog-flow + process: workflow + input: AI + agents: + researcher: + role: Researcher + goal: Research + instructions: You research. + writer: + role: Writer + goal: Write + instructions: You write. + steps: + - name: research + agent: researcher + action: "Research {{input}}" + - name: draft + agent: writer + action: "Write about {{previous_output}}" +""") + + +@pytest.fixture +def flow_yaml(tmp_path, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "sk-test-not-used") + y = tmp_path / "flow.yaml" + y.write_text(FLOW_YAML) + return y + + +# --------------------------------------------------------------------------- +# flow +# --------------------------------------------------------------------------- + +def test_flow_mermaid_on_yaml_is_the_static_graph(flow_yaml, monkeypatch): + from praisonai.replay import yaml_run + + result = _invoke("flow", str(flow_yaml), "--format", "mermaid") + assert result.exit_code == 0, result.output + expected = yaml_run.static_mermaid(flow_yaml) + assert result.output == expected + assert result.output.startswith(("graph", "flowchart")) + assert "researcher" in result.output and "writer" in result.output + + +def test_flow_mermaid_on_yaml_falls_back_without_core_to_graph(flow_yaml, monkeypatch): + """Without ``to_graph`` in the SDK, the existing AgentFlow.to_mermaid() is used.""" + from praisonai.replay import yaml_run + + monkeypatch.setattr(yaml_run, "_core_diagram", lambda name: None) + result = _invoke("flow", str(flow_yaml), "--format", "mermaid") + assert result.exit_code == 0, result.output + assert result.output == yaml_run.load_flow(flow_yaml).to_mermaid() + + +def test_flow_json_on_yaml_is_a_graph(flow_yaml): + result = _invoke("flow", str(flow_yaml), "--format", "json") + assert result.exit_code == 0, result.output + graph = json.loads(result.output) + assert graph["schema"] == "praisonai.graph/1" + ids = [n["id"] for n in graph["nodes"]] + assert "start:begin" in ids and "end:done" in ids + + +def test_flow_mermaid_on_a_run_is_a_sequence_diagram(traces_dir, fx): + fx.write_trace(traces_dir, "run-team01", fx.team_events()) + result = _invoke("flow", "run-team01", "--format", "mermaid") + assert result.exit_code == 0, result.output + assert result.output.startswith("sequenceDiagram\n") + assert "A0->>A1: handoff: draft it" in result.output + + +def test_flow_rejects_unknown_format(traces_dir, fx): + fx.write_trace(traces_dir, "run-team01", fx.team_events()) + assert _invoke("flow", "run-team01", "--format", "dot").exit_code == 2 + + +# --------------------------------------------------------------------------- +# 1.0 traces keep working with show / flow +# --------------------------------------------------------------------------- + +def test_legacy_trace_show_and_flow(traces_dir, fx): + fx.write_trace(traces_dir, "run-legacy1", fx.legacy_events()) + shown = _invoke("show", "run-legacy1", "--json") + assert shown.exit_code == 0, shown.output + assert json.loads(shown.output)["total_events"] == 9 + + flow = _invoke("flow", "run-legacy1") + assert flow.exit_code == 0, flow.output + assert "Researcher" in flow.output and "Writer" in flow.output + + flow_json = _invoke("flow", "run-legacy1", "--format", "json") + assert json.loads(flow_json.output)["agent_order"] == ["Researcher", "Writer"] + + mermaid = _invoke("flow", "run-legacy1", "--format", "mermaid") + assert mermaid.exit_code == 0 and "A0->>A1: handoff: next" in mermaid.output + + +def test_schema_1_1_trace_show_and_flow(traces_dir, fx): + """New event types must not break the existing readers.""" + fx.write_trace(traces_dir, "run-team01", fx.team_events()) + assert _invoke("show", "run-team01").exit_code == 0 + flow = _invoke("flow", "run-team01", "--format", "json") + assert flow.exit_code == 0 + assert set(json.loads(flow.output)["agent_order"]) == {"Researcher"} + + +# --------------------------------------------------------------------------- +# export +# --------------------------------------------------------------------------- + +def test_export_html_default_file(traces_dir, fx, tmp_path, monkeypatch): + from praisonai.replay import export + + monkeypatch.setattr(export, "aiui_renderer_files", lambda: None) + fx.write_trace(traces_dir, "run-team01", fx.team_events()) + monkeypatch.chdir(tmp_path) + result = _invoke("export", "run-team01") + assert result.exit_code == 0, result.output + html = (tmp_path / "run-team01.html").read_text() + assert "window.__REPLAY_DATA__" in html + assert "sk-abcdefghijklmnop1234" not in html + + +@pytest.mark.parametrize("fmt", ["json", "mermaid", "otel"]) +def test_export_formats_to_file(traces_dir, fx, tmp_path, fmt): + fx.write_trace(traces_dir, "run-team01", fx.team_events()) + out = tmp_path / f"out.{fmt}" + result = _invoke("export", "run-team01", "--format", fmt, "-o", str(out)) + assert result.exit_code == 0, result.output + text = out.read_text() + assert "sk-abcdefghijklmnop1234" not in text + if fmt == "json": + assert json.loads(text)["run_id"] == "run-team01" + elif fmt == "otel": + assert json.loads(text)["resourceSpans"][0]["scopeSpans"][0]["spans"] + else: + assert text.startswith("sequenceDiagram") + + +def test_export_no_redact(traces_dir, fx): + fx.write_trace(traces_dir, "run-team01", fx.team_events()) + result = _invoke("export", "run-team01", "--format", "json", "--no-redact") + assert result.exit_code == 0 + assert "sk-abcdefghijklmnop1234" in result.output + + +def test_export_missing_run(traces_dir): + result = _invoke("export", "run-nope", "--format", "json") + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# view (aiui is never actually started here) +# --------------------------------------------------------------------------- + +class _FakeProc: + def wait(self, timeout=None): + return 0 + + def poll(self): + return 0 + + def terminate(self): + pass + + +@pytest.fixture +def fake_aiui(monkeypatch): + from praisonai.cli.commands import replay as replay_cli + + calls = {} + + def spawn(app_path, host, port, env): + calls["spawn"] = {"app": app_path, "host": host, "port": port, "env": env} + return _FakeProc() + + monkeypatch.setattr(replay_cli, "_aiui_installed", lambda: True) + monkeypatch.setattr(replay_cli, "_spawn_aiui", spawn) + monkeypatch.setattr(replay_cli, "_open_browser", lambda url, host, port: calls.setdefault("url", url)) + monkeypatch.setattr(replay_cli, "_start_yaml_run", lambda y, run_id: calls.setdefault("yaml_run", (y, run_id))) + return calls + + +def test_view_help(): + result = _invoke("view", "--help") + assert result.exit_code == 0 + for flag in ("--live", "--port", "--host"): + assert flag in result.output + + +def test_view_run_opens_replay_page(traces_dir, fx, fake_aiui): + fx.write_trace(traces_dir, "run-team01", fx.team_events()) + result = _invoke("view", "run-team01") + assert result.exit_code == 0, result.output + assert fake_aiui["url"] == "http://127.0.0.1:8090/#/replay?run=run-team01" + spawn = fake_aiui["spawn"] + assert spawn["app"].name == "default_app.py" and spawn["app"].parent.name == "ui_replay" + assert spawn["app"].exists() + assert (spawn["host"], spawn["port"]) == ("127.0.0.1", 8090) + assert spawn["env"]["PRAISONAI_TRACES_DIR"] == str(traces_dir) + assert "WARNING" not in result.output + + +def test_view_jsonl_path_serves_its_directory(tmp_path, fx, fake_aiui, monkeypatch): + monkeypatch.delenv("PRAISONAI_TRACES_DIR", raising=False) + path = fx.write_trace(tmp_path / "elsewhere", "run-team01", fx.team_events()) + result = _invoke("view", str(path), "--port", "9001") + assert result.exit_code == 0, result.output + assert fake_aiui["spawn"]["env"]["PRAISONAI_TRACES_DIR"] == str(tmp_path / "elsewhere") + assert fake_aiui["url"] == "http://127.0.0.1:9001/#/replay?run=run-team01" + + +def test_view_yaml_runs_it_recorded_live(traces_dir, flow_yaml, fake_aiui): + result = _invoke("view", str(flow_yaml)) + assert result.exit_code == 0, result.output + yaml_file, run_id = fake_aiui["yaml_run"] + assert yaml_file == str(flow_yaml) and run_id.startswith("run-") + assert fake_aiui["url"] == f"http://127.0.0.1:8090/#/replay?run={run_id}&live=1" + + +def test_view_non_loopback_host_warns(traces_dir, fx, fake_aiui): + fx.write_trace(traces_dir, "run-team01", fx.team_events()) + result = _invoke("view", "run-team01", "--host", "0.0.0.0") + assert result.exit_code == 0 + assert "WARNING" in result.output and "0.0.0.0" in result.output + assert fake_aiui["url"].startswith("http://127.0.0.1:8090/") + + +def test_view_missing_run_fails(traces_dir, fake_aiui): + result = _invoke("view", "run-nope") + assert result.exit_code == 1 + assert "spawn" not in fake_aiui + + +def test_view_live_allows_a_run_that_has_not_started(traces_dir, fake_aiui): + result = _invoke("view", "run-soon", "--live") + assert result.exit_code == 0 + assert fake_aiui["url"].endswith("?run=run-soon&live=1") + + +def test_view_without_aiui_gives_install_hint(traces_dir, monkeypatch): + from praisonai.cli.commands import replay as replay_cli + + spawned = [] + monkeypatch.setattr(replay_cli, "_aiui_installed", lambda: False) + monkeypatch.setattr(replay_cli, "_spawn_aiui", lambda *a: spawned.append(a)) + result = _invoke("view") + assert result.exit_code == 1 + assert 'pip install "praisonai[ui]"' in result.output + assert spawned == [] + + +def test_replay_preset_targets_the_replay_page(): + import ast + + from praisonai.cli.commands.replay import _replay_app_path + + tree = ast.parse(_replay_app_path().read_text()) + pages = [ + kw.value for node in ast.walk(tree) if isinstance(node, ast.Call) + for kw in node.keywords if kw.arg == "pages" + ] + assert pages and ast.literal_eval(pages[0]) == ["replay"] + + +def test_view_stops_the_server_when_the_cli_exits(): + from praisonai.cli.commands.replay import _wait_for_server + + class Running: + terminated = False + + def wait(self, timeout=None): + if timeout is None: + raise KeyboardInterrupt + return 0 + + def poll(self): + return None if not self.terminated else 0 + + def terminate(self): + self.terminated = True + + proc = Running() + assert _wait_for_server(proc) == 0 + assert proc.terminated From d49a32f713d662fd99286289e7d6f7be70ee83ec Mon Sep 17 00:00:00 2001 From: MervinPraison <454862+MervinPraison@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:32:30 +0000 Subject: [PATCH 04/12] feat(replay): rerun a recorded run from a node (P4) replay rerun RUN --from NODE_ID [--file YAML]; Python: praisonai.replay.rerun.rerun(flow_or_team, run, node_id). - plan_rerun reads the recording: outputs of every task/step that finished before NODE started (completed/cached). A preview cut at 500 chars is recovered from the span's last llm_response, otherwise the rerun refuses rather than feed the next node a truncated input. 1.0 traces (no node_id) and unknown nodes fail with a clear message. - AgentFlow: TraceStepCache implements StepCacheProtocol and serves the recorded outputs through the flow's own cache-hit path (variables, previous_output, callbacks), identifying the asking step by the "running" status the flow sets just before the lookup; set() is a no-op so NODE onwards always executes. - AgentTeam: the team's checkpoint restore (_restore_serialised_task_state) marks earlier tasks completed with their recorded output; tasks from NODE on are reset to "not started". - YAML runs: a zero-cost ContextVar hook (replay/_hook.py) seeds the team/flow AgentsGenerator builds; the CLI checks the YAML kind first so a mismatch is refused before any LLM call. - The new run records forked_from = {run_id, node_id} on session_start and on its first workflow_start (sink wrapper, no core change needed). Co-Authored-By: Claude Opus 5.5 (1M context) --- src/praisonai/praisonai/agents_generator.py | 4 + .../praisonai/cli/commands/replay.py | 61 ++- .../framework_adapters/praisonai_adapter.py | 5 + src/praisonai/praisonai/replay/_hook.py | 22 ++ src/praisonai/praisonai/replay/rerun.py | 353 ++++++++++++++++++ .../tests/unit/replay/test_replay_rerun.py | 316 ++++++++++++++++ 6 files changed, 760 insertions(+), 1 deletion(-) create mode 100644 src/praisonai/praisonai/replay/_hook.py create mode 100644 src/praisonai/praisonai/replay/rerun.py create mode 100644 src/praisonai/tests/unit/replay/test_replay_rerun.py diff --git a/src/praisonai/praisonai/agents_generator.py b/src/praisonai/praisonai/agents_generator.py index 8d6ecb7a4e..db458d02ea 100644 --- a/src/praisonai/praisonai/agents_generator.py +++ b/src/praisonai/praisonai/agents_generator.py @@ -1980,6 +1980,10 @@ def _build_yaml_workflow(self, config): parser = YAMLWorkflowParser() workflow = parser.parse_string(yaml_content) + # ``praisonai replay rerun``: reuse recorded step outputs (no-op otherwise). + from .replay._hook import apply_rerun_seed + apply_rerun_seed(workflow) + # Get input: 'input' is canonical, 'topic' is alias for backward compatibility input_data = config.get('input', config.get('topic', '')) diff --git a/src/praisonai/praisonai/cli/commands/replay.py b/src/praisonai/praisonai/cli/commands/replay.py index 99f80fcb07..2c1defeecf 100644 --- a/src/praisonai/praisonai/cli/commands/replay.py +++ b/src/praisonai/praisonai/cli/commands/replay.py @@ -7,6 +7,7 @@ - replay flow --format ascii|json|mermaid: Agent flow / static graph - replay view [run|yaml] [--live]: Open PraisonAI Replay (aiui) on a run - replay export --format html|json|mermaid|otel: Export a run +- replay rerun --from NODE: Re-run from a node, reusing earlier outputs """ from typing import Optional @@ -884,7 +885,7 @@ def replay_cleanup( # --------------------------------------------------------------------------- -# PraisonAI Replay: view / export +# PraisonAI Replay: view / export / rerun # --------------------------------------------------------------------------- _AIUI_INSTALL_HINT = 'pip install "praisonai[ui]"' @@ -1143,3 +1144,61 @@ def replay_export( typer.echo(f"Wrote {format} export to {output_file}") else: typer.echo(text, nl=not text.endswith("\n")) + + +@app.command("rerun") +def replay_rerun( + run: str = typer.Argument(..., help="Run id or path to the recorded trace"), + from_node: str = typer.Option(..., "--from", help="Node id to re-run from (e.g. task:write, step:draft)"), + file: Optional[str] = typer.Option( + None, "--file", help="YAML definition (default: the one recorded with the run)" + ), +): + """Re-run a recorded run from NODE: earlier nodes reuse their recorded output. + + The new run is recorded with workflow_start.data.forked_from = {run_id, node_id}. + """ + from pathlib import Path + + from ..output.console import get_output_controller + from praisonai.replay.events import session_meta + from praisonai.replay.recorder import new_run_id + from praisonai.replay.rerun import RerunError, plan_rerun + from praisonai.replay.yaml_run import run_yaml_recorded, yaml_kind + + output = get_output_controller() + events = _load_raw_events(output, run) + try: + plan = plan_rerun(events, from_node) + except RerunError as e: + output.print_error(str(e)) + raise typer.Exit(1) + + meta = session_meta(events) + definition = file or meta.get("yaml_path") or meta.get("agents_file") or meta.get("workflow") + if not definition: + output.print_error("This run did not record its definition; pass it with --file agents.yaml") + raise typer.Exit(1) + if not Path(definition).expanduser().is_file(): + output.print_error(f"Definition not found: {definition} (pass --file)") + raise typer.Exit(1) + if yaml_kind(definition) != plan.kind: + output.print_error( + f"{definition} defines a {yaml_kind(definition)} but {from_node} is a {plan.kind} node; " + "pass the definition that recorded this run with --file." + ) + raise typer.Exit(1) + + seeded = [] + new_id = new_run_id() + reused = ", ".join(plan.outputs) or "nothing" + typer.echo(f"Re-running {plan.source_run_id} from {from_node} as {new_id} (reusing: {reused})") + try: + rec, result = run_yaml_recorded(definition, run_id=new_id, plan=plan, on_seeded=seeded.append) + except Exception as e: + output.print_error(f"Re-run failed: {e}") + raise typer.Exit(1) + if not seeded: + output.print_warning("The definition was run, but the recorded outputs could not be applied to it.") + typer.echo(str(result) if result is not None else "") + typer.echo(f"Recorded: {rec.run_id} ({rec.path})") diff --git a/src/praisonai/praisonai/framework_adapters/praisonai_adapter.py b/src/praisonai/praisonai/framework_adapters/praisonai_adapter.py index 876463b370..49ff9ca2a2 100644 --- a/src/praisonai/praisonai/framework_adapters/praisonai_adapter.py +++ b/src/praisonai/praisonai/framework_adapters/praisonai_adapter.py @@ -992,6 +992,11 @@ async def arun( else: logger.info(f"No prior state for session: {resume_session}") + # ``praisonai replay rerun``: restore recorded tasks before NODE and + # reset the rest (no-op unless a re-run is in progress). + from praisonai.replay._hook import apply_rerun_seed + apply_rerun_seed(team) + # Bridge the team's aggregate per-step events onto the CLI # structured output stream so `--output stream-json` on a YAML/team # run emits the same per-agent NDJSON events as a single-agent run. diff --git a/src/praisonai/praisonai/replay/_hook.py b/src/praisonai/praisonai/replay/_hook.py new file mode 100644 index 0000000000..9ab608224f --- /dev/null +++ b/src/praisonai/praisonai/replay/_hook.py @@ -0,0 +1,22 @@ +"""Zero-cost hook the YAML runner calls on the team/flow it builds. + +Kept apart from :mod:`praisonai.replay.rerun` (which imports the workflow +engine) so a normal run pays one ContextVar lookup and nothing else. +""" + +import contextvars +from typing import Any + +#: (RerunPlan, on_seeded) while ``praisonai.replay.rerun.rerun_scope`` is active. +ACTIVE_PLAN: contextvars.ContextVar = contextvars.ContextVar("praisonai_replay_rerun_plan", default=None) + + +def apply_rerun_seed(obj: Any) -> None: + """Seed ``obj`` from the active re-run plan, if any (no-op otherwise).""" + active = ACTIVE_PLAN.get() + if active is None: + return + from .rerun import _seed_built + + plan, on_seeded = active + _seed_built(obj, plan, on_seeded) diff --git a/src/praisonai/praisonai/replay/rerun.py b/src/praisonai/praisonai/replay/rerun.py new file mode 100644 index 0000000000..0b6f723ab3 --- /dev/null +++ b/src/praisonai/praisonai/replay/rerun.py @@ -0,0 +1,353 @@ +"""Re-run a recorded run from one node: ``praisonai replay rerun RUN --from NODE``. + +Everything before NODE is served from the recording instead of being executed +again; NODE and everything after it runs fresh, and the new run is recorded +with ``workflow_start.data.forked_from = {run_id, node_id}``. + +- **AgentFlow** -- :class:`TraceStepCache`, a ``StepCacheProtocol`` seeded with + the recorded ``step_end`` outputs. The flow's own cache-hit path replays them, + so variables, ``{{previous_output}}`` and callbacks behave as on a real hit. +- **AgentTeam** -- the team's existing task-state restore: earlier tasks come + back ``completed`` with their recorded output (which the process layer then + skips), tasks from NODE on are reset to ``not started``. + +Python:: + + from praisonai.replay.rerun import rerun + rec, result = rerun(flow, "run-1a2b3c4d5e6f", "step:draft", input="AI") + +Needs a schema 1.1 trace (events carry ``node_id``). A 1.0 trace, an unknown +node or an output that was only recorded as a truncated preview fail with +:class:`RerunError` rather than silently re-running with the wrong inputs. +""" + +from __future__ import annotations + +import contextlib +import threading +from collections import OrderedDict +from typing import Any, Callable, Dict, Iterator, List, Optional, Union + +from praisonaiagents.workflows.step_cache import StepCacheProtocol + +from .events import as_dicts, etype, load_events, pair_spans, run_id_of + +__all__ = [ + "RerunError", + "RerunPlan", + "TraceStepCache", + "plan_rerun", + "rerun", + "rerun_scope", + "seed_flow", + "seed_team", +] + +#: Previews are truncated to this many characters by the emitter (contract ยง2). +PREVIEW_LIMIT = 500 +_REUSABLE = {"completed", "cached"} + + +class RerunError(RuntimeError): + """The recording cannot support a re-run from the requested node.""" + + +class RerunPlan: + """What to reuse: recorded outputs (in order) for every node before ``node_id``.""" + + def __init__(self, source_run_id: str, node_id: str, kind: str, outputs: "OrderedDict[str, Any]"): + self.source_run_id = source_run_id + self.node_id = node_id + self.kind = kind # "flow" | "team" + self.outputs = outputs + + @property + def forked_from(self) -> Dict[str, str]: + return {"run_id": self.source_run_id, "node_id": self.node_id} + + def __repr__(self) -> str: + return f"RerunPlan(from={self.node_id!r}, reuse={list(self.outputs)!r})" + + +# --------------------------------------------------------------------------- +# Reading the recording +# --------------------------------------------------------------------------- + +def _looks_truncated(preview: str) -> bool: + return len(preview) >= PREVIEW_LIMIT - 3 or preview.endswith(("โ€ฆ", "...")) + + +def _recover_output(span: Dict[str, Any], events: List[Dict[str, Any]]) -> Any: + """The full output of a finished task/step, or RerunError if only a stub exists.""" + end_data = span["end"].get("data") or {} + if "output" in end_data and end_data["output"] is not None: + return end_data["output"] + preview = end_data.get("output_preview") + if preview is None: + raise RerunError( + f"{span['start'].get('node_id')} has no recorded output (no output/output_preview " + "on its end event); re-record the run with this version of PraisonAI." + ) + preview = str(preview) + if not _looks_truncated(preview): + return preview + # The preview was cut at 500 chars: the agent's last LLM answer inside the + # span is the untruncated text when it starts the same way. + head = preview.rstrip("โ€ฆ").rstrip(".")[:100] + lo, hi = span["seq"], span["end"].get("sequence_num", float("inf")) + for ev in reversed(events): + seq = ev.get("sequence_num") + if not isinstance(seq, int) or not (lo < seq < hi) or etype(ev) != "llm_response": + continue + content = (ev.get("data") or {}).get("response_content") + if isinstance(content, str) and content.startswith(head): + return content + raise RerunError( + f"Only a truncated preview of {span['start'].get('node_id')}'s output was recorded, so " + "re-running after it would feed the next node the wrong input. Re-record with " + "full content (praisonai.replay.record(full_content=True)) or re-run from an earlier node." + ) + + +def plan_rerun(events: Union[str, List[Any]], node_id: str) -> RerunPlan: + """Work out which recorded outputs to reuse for a re-run from ``node_id``.""" + evs = load_events(events) if isinstance(events, str) else as_dicts(events) + if not evs: + raise RerunError("The trace is empty.") + if not any(ev.get("node_id") for ev in evs): + raise RerunError( + "This trace has no node ids (schema 1.0). Re-running from a node needs a run " + "recorded with PraisonAI Replay schema 1.1 or later; record it again." + ) + spans, _ = pair_spans(evs) + nodes = [s for s in spans if s["type"] in ("task_start", "step_start") and s["start"].get("node_id")] + target = next((s for s in nodes if s["start"]["node_id"] == node_id), None) + if target is None: + known = ", ".join(dict.fromkeys(s["start"]["node_id"] for s in nodes)) or "none" + raise RerunError(f"Node {node_id!r} is not in this trace. Re-runnable nodes: {known}") + kind = "team" if target["type"] == "task_start" else "flow" + for ev in evs: + if etype(ev) == "workflow_start": + recorded = (ev.get("data") or {}).get("kind") + if recorded in ("team", "flow"): + kind = recorded + break + + outputs: "OrderedDict[str, Any]" = OrderedDict() + for span in nodes: + if span["type"] != target["type"] or span["end"] is None: + continue + end_seq = span["end"].get("sequence_num") + # Reuse only what had FINISHED before NODE started: that excludes NODE, + # everything after it, and containers that enclose it. + if not isinstance(end_seq, int) or end_seq >= target["seq"]: + continue + status = (span["end"].get("data") or {}).get("status", "completed") + if status not in _REUSABLE: + continue + outputs[span["start"]["node_id"]] = _recover_output(span, evs) + return RerunPlan(run_id_of(evs), node_id, kind, outputs) + + +# --------------------------------------------------------------------------- +# AgentFlow: a step cache seeded from the recording +# --------------------------------------------------------------------------- + +def _step_name(node_id: str) -> str: + """``step:draft#2`` -> ``draft`` (duplicate suffixes are served in order).""" + name = node_id.split(":", 1)[1] if ":" in node_id else node_id + head, sep, tail = name.rpartition("#") + return head if sep and tail.isdigit() else name + + +class TraceStepCache(StepCacheProtocol): + """``StepCacheProtocol`` that serves recorded outputs for the steps before NODE. + + It never caches anything new: a miss means "run this step", and ``set`` is + a no-op so later steps always execute. Bind it to the flow + (:func:`seed_flow` does) so it can tell which step is asking -- the flow + marks the step ``running`` just before it consults the cache. + + ``hits`` lists the step names served from the recording. + """ + + def __init__(self, outputs: "OrderedDict[str, Any]"): + self._queues: Dict[str, List[Any]] = {} + for node_id, output in outputs.items(): + self._queues.setdefault(_step_name(node_id), []).append(output) + self._flow: Any = None + self._lock = threading.Lock() + self.hits: List[str] = [] + self.misses = 0 + + def bind(self, flow: Any) -> "TraceStepCache": + self._flow = flow + return self + + def _current_step(self) -> Optional[str]: + statuses = getattr(self._flow, "step_statuses", None) or {} + running = [name for name, status in statuses.items() if status == "running" and self._queues.get(name)] + # Two candidates (parallel branches) are ambiguous: run them fresh + # rather than risk serving one step another's output. + return running[0] if len(running) == 1 else None + + def _step_obj(self, name: str) -> Any: + for step in getattr(self._flow, "steps", None) or []: + if getattr(step, "name", None) == name or getattr(step, "__name__", None) == name: + return step + return None + + def get(self, key: str) -> Optional[Dict[str, Any]]: + with self._lock: + name = self._current_step() + if name is None: + self.misses += 1 + return None + output = self._queues[name].pop(0) + self.hits.append(name) + step = self._step_obj(name) + var = getattr(step, "output_variable", None) or f"{name}_output" + return { + "output": output, + "variables": {var: output}, + "stop": False, + "step_record": {"step": name, "output": output, "status": "completed", "retries": 0}, + } + + def set(self, key: str, value: Dict[str, Any]) -> None: + return None + + +def seed_flow(flow: Any, plan: RerunPlan) -> TraceStepCache: + """Install a :class:`TraceStepCache` for ``plan`` on an AgentFlow.""" + from praisonaiagents.workflows.step_cache import resolve_step_cache + + cache = TraceStepCache(plan.outputs).bind(flow) + flow.cache = cache + # AgentFlow resolves ``cache=`` once at construction; resolve the new one + # the same way so the run uses it. + flow._step_cache = resolve_step_cache(cache) + return cache + + +# --------------------------------------------------------------------------- +# AgentTeam: restore finished tasks, reset the rest +# --------------------------------------------------------------------------- + +def _task_key(team: Any, node_id: str) -> Any: + ref = node_id.split(":", 1)[1] if ":" in node_id else node_id + tasks = getattr(team, "tasks", {}) or {} + for key, task in tasks.items(): + if getattr(task, "name", None) == ref: + return key + for index, key in enumerate(tasks): + if str(index) == ref or str(key) == ref: + return key + return None + + +def seed_team(team: Any, plan: RerunPlan) -> List[Any]: + """Mark the tasks before NODE completed (with their recorded output), reset the rest. + + Returns the task keys restored from the recording. + """ + tasks = getattr(team, "tasks", None) + if not isinstance(tasks, dict): + raise RerunError("Expected an AgentTeam (an object with a tasks dict).") + snapshot: Dict[str, Dict[str, Any]] = {} + restored: List[Any] = [] + for node_id, output in plan.outputs.items(): + key = _task_key(team, node_id) + if key is None: + raise RerunError(f"Recorded node {node_id!r} does not match any task in this team.") + snapshot[str(key)] = {"status": "completed", "result": output} + restored.append(key) + if _task_key(team, plan.node_id) is None: + raise RerunError(f"Node {plan.node_id!r} does not match any task in this team.") + + for key, task in tasks.items(): + if key in restored: + continue + task.status = "not started" + task.result = None + if hasattr(task, "retry_count"): + task.retry_count = 0 + + restore = getattr(team, "_restore_serialised_task_state", None) + if callable(restore): + restore(snapshot) # the team's own checkpoint-restore path (wraps text as TaskOutput) + else: # pragma: no cover - older praisonaiagents + for key in restored: + tasks[key].status = "completed" + tasks[key].result = snapshot[str(key)]["result"] + return restored + + +# --------------------------------------------------------------------------- +# Seeding objects built elsewhere (YAML runs go through AgentsGenerator) +# --------------------------------------------------------------------------- + +def _is_team(obj: Any) -> bool: + return isinstance(getattr(obj, "tasks", None), dict) and hasattr(obj, "run_all_tasks") + + +def _is_flow(obj: Any) -> bool: + return hasattr(obj, "step_statuses") and hasattr(obj, "steps") + + +def _seed_built(obj: Any, plan: RerunPlan, on_seeded: Callable[[Any], None]) -> None: + """Seed a team/flow the YAML runner just built (called by ``_hook``).""" + if plan.kind == "flow" and _is_flow(obj): + on_seeded(seed_flow(obj, plan)) + elif plan.kind == "team" and _is_team(obj): + on_seeded(seed_team(obj, plan)) + + +@contextlib.contextmanager +def rerun_scope(plan: RerunPlan, on_seeded: Optional[Callable[[Any], None]] = None) -> Iterator[RerunPlan]: + """While active, the next team/flow built by the YAML runner is seeded from ``plan``.""" + from . import _hook + + token = _hook.ACTIVE_PLAN.set((plan, on_seeded or (lambda _x: None))) + try: + yield plan + finally: + _hook.ACTIVE_PLAN.reset(token) + + +# --------------------------------------------------------------------------- +# Python entry point +# --------------------------------------------------------------------------- + +def rerun( + target: Any, + source: Union[str, List[Any]], + node_id: str, + *, + input: Any = None, + run_id: Optional[str] = None, + live: bool = False, + meta: Optional[Dict[str, Any]] = None, +): + """Re-run ``target`` (an AgentFlow or AgentTeam) from ``node_id`` of ``source``. + + Returns ``(recording, result)``; the new trace is at ``recording.path``. + """ + from .recorder import record + + plan = plan_rerun(source, node_id) + if _is_flow(target): + if plan.kind != "flow": + raise RerunError(f"{node_id!r} is a team task; pass the AgentTeam that recorded it.") + seed_flow(target, plan) + elif _is_team(target): + if plan.kind != "team": + raise RerunError(f"{node_id!r} is a flow step; pass the AgentFlow that recorded it.") + seed_team(target, plan) + else: + raise RerunError(f"Cannot re-run a {type(target).__name__}; expected an AgentFlow or AgentTeam.") + + start_meta = dict(meta or {}) + with record(run_id, live=live, meta=start_meta, _forked_from=plan.forked_from) as rec: + result = target.start(input) if input is not None else target.start() + return rec, result diff --git a/src/praisonai/tests/unit/replay/test_replay_rerun.py b/src/praisonai/tests/unit/replay/test_replay_rerun.py new file mode 100644 index 0000000000..8c9ba1663d --- /dev/null +++ b/src/praisonai/tests/unit/replay/test_replay_rerun.py @@ -0,0 +1,316 @@ +"""``praisonai replay rerun``: reuse recorded outputs before NODE, run NODE onwards fresh.""" + +import json +import textwrap + +import pytest + + +@pytest.fixture +def fake_llm(monkeypatch): + """Replace every Agent LLM call with a counted, deterministic answer.""" + from praisonaiagents import Agent + + calls = [] + + def fake_chat(self, prompt, *args, **kwargs): + calls.append((self.name, str(prompt))) + return f"{self.name} fresh output" + + async def fake_achat(self, prompt, *args, **kwargs): + return fake_chat(self, prompt) + + monkeypatch.setattr(Agent, "chat", fake_chat) + monkeypatch.setattr(Agent, "achat", fake_achat) + monkeypatch.setenv("OPENAI_API_KEY", "sk-test-not-used") + return calls + + +def _lines(path): + return [json.loads(l) for l in open(path, encoding="utf-8") if l.strip()] + + +# --------------------------------------------------------------------------- +# plan_rerun: what the recording can support +# --------------------------------------------------------------------------- + +def test_plan_rejects_schema_1_0_trace(fx): + from praisonai.replay.rerun import RerunError, plan_rerun + + with pytest.raises(RerunError, match="schema 1.0"): + plan_rerun(fx.legacy_events(), "task:write") + + +def test_plan_rejects_unknown_node_and_lists_known(fx): + from praisonai.replay.rerun import RerunError, plan_rerun + + with pytest.raises(RerunError, match="task:research, task:write"): + plan_rerun(fx.team_events(), "task:nope") + + +def test_plan_reuses_only_nodes_finished_before_target(fx): + from praisonai.replay.rerun import plan_rerun + + plan = plan_rerun(fx.flow_events(), "step:polish") + assert plan.kind == "flow" + assert dict(plan.outputs) == {"step:research": "research output", "step:draft": "draft output"} + assert plan.forked_from == {"run_id": "run-flow01", "node_id": "step:polish"} + assert dict(plan_rerun(fx.flow_events(), "step:research").outputs) == {} + + +def test_plan_recovers_long_output_from_llm_response(fx): + from praisonai.replay.rerun import plan_rerun + + full = "Findings: " + "x" * 900 + evs = fx.team_events() + evs[5]["data"]["response_content"] = full + evs[9]["data"]["output_preview"] = full[:500] + assert plan_rerun(evs, "task:write").outputs["task:research"] == full + + +def test_plan_refuses_truncated_output_it_cannot_recover(fx): + from praisonai.replay.rerun import RerunError, plan_rerun + + evs = fx.team_events() + evs[5]["data"]["response_content"] = "something else" + evs[9]["data"]["output_preview"] = "y" * 500 + with pytest.raises(RerunError, match="truncated preview"): + plan_rerun(evs, "task:write") + + +# --------------------------------------------------------------------------- +# AgentFlow: TraceStepCache +# --------------------------------------------------------------------------- + +def _counting_flow(): + from praisonaiagents import AgentFlow + from praisonaiagents.workflows import StepResult + + calls = [] + + def research(ctx): + calls.append("research") + return StepResult(output="research FRESH") + + def draft(ctx): + calls.append("draft") + return StepResult(output=f"draft of [{ctx.previous_result}]") + + def polish(ctx): + calls.append("polish") + return StepResult(output=f"polished [{ctx.previous_result}]") + + return AgentFlow(steps=[research, draft, polish]), calls + + +def test_trace_step_cache_implements_step_cache_protocol(): + from praisonaiagents.workflows.step_cache import StepCacheProtocol, resolve_step_cache + from praisonai.replay.rerun import TraceStepCache + + cache = TraceStepCache({}) + assert isinstance(cache, StepCacheProtocol) + assert resolve_step_cache(cache) is cache + assert cache.get("k") is None and cache.set("k", {}) is None + + +def test_flow_rerun_reuses_earlier_steps_and_runs_later_ones(traces_dir, fx): + from praisonai.replay.rerun import rerun + + src = fx.write_trace(traces_dir, "run-flow01", fx.flow_events()) + flow, calls = _counting_flow() + rec, result = rerun(flow, str(src), "step:draft", input="topic") + + assert calls == ["draft", "polish"] # research came from the recording + assert result["output"] == "polished [draft of [research output]]" + assert flow._step_cache.hits == ["research"] + + events = _lines(rec.path) + assert rec.run_id != "run-flow01" + assert events[0]["data"]["forked_from"] == {"run_id": "run-flow01", "node_id": "step:draft"} + for ev in events: + if ev["event_type"] == "workflow_start": # schema 1.1 core + assert ev["data"]["forked_from"] == {"run_id": "run-flow01", "node_id": "step:draft"} + + +def test_flow_rerun_from_last_step(traces_dir, fx): + from praisonai.replay.rerun import rerun + + src = fx.write_trace(traces_dir, "run-flow01", fx.flow_events()) + flow, calls = _counting_flow() + _rec, result = rerun(flow, str(src), "step:polish", input="topic") + assert calls == ["polish"] + assert result["output"] == "polished [draft output]" + + +def test_rerun_rejects_wrong_object_kind(traces_dir, fx): + from praisonai.replay.rerun import RerunError, rerun + + src = fx.write_trace(traces_dir, "run-team01", fx.team_events()) + flow, _calls = _counting_flow() + with pytest.raises(RerunError, match="team task"): + rerun(flow, str(src), "task:write") + + +# --------------------------------------------------------------------------- +# AgentTeam: restore finished tasks, reset the rest +# --------------------------------------------------------------------------- + +def _team(): + from praisonaiagents import Agent, AgentTeam, Task + + r = Agent(name="Researcher", instructions="research") + w = Agent(name="Writer", instructions="write") + t1 = Task(name="research", description="Research AI", expected_output="notes", agent=r) + t2 = Task(name="write", description="Write post", expected_output="post", agent=w) + return AgentTeam(agents=[r, w], tasks=[t1, t2]) + + +def test_team_rerun_skips_earlier_tasks(traces_dir, fx, fake_llm): + from praisonai.replay.rerun import rerun + + src = fx.write_trace(traces_dir, "run-team01", fx.team_events()) + team = _team() + rec, result = rerun(team, str(src), "task:write") + + assert [name for name, _ in fake_llm] == ["Writer"] # Researcher not re-run + assert "Findings: AI is big" in fake_llm[0][1] # recorded output fed forward + assert "Writer fresh output" in str(result) + assert team.tasks[0].status == "completed" + assert _lines(rec.path)[0]["data"]["forked_from"] == {"run_id": "run-team01", "node_id": "task:write"} + + +def test_team_rerun_from_first_task_runs_everything(traces_dir, fx, fake_llm): + from praisonai.replay.rerun import rerun + + src = fx.write_trace(traces_dir, "run-team01", fx.team_events()) + rerun(_team(), str(src), "task:research") + assert [name for name, _ in fake_llm] == ["Researcher", "Writer"] + + +def test_seed_team_resets_tasks_from_node(fx): + from praisonai.replay.rerun import plan_rerun, seed_team + + team = _team() + for task in team.tasks.values(): + task.status = "completed" + task.result = "stale" + restored = seed_team(team, plan_rerun(fx.team_events(), "task:write")) + assert restored == [0] + assert team.tasks[0].status == "completed" + assert getattr(team.tasks[0].result, "raw", team.tasks[0].result) == "Findings: AI is big" + assert team.tasks[1].status == "not started" and team.tasks[1].result is None + + +# --------------------------------------------------------------------------- +# CLI: praisonai replay rerun (YAML definitions through the real runner) +# --------------------------------------------------------------------------- + +FLOW_YAML = textwrap.dedent("""\ + name: blog-flow + process: workflow + workflow: + default_llm: gpt-4o-mini + input: AI agents + agents: + researcher: + role: Researcher + goal: Research topics + instructions: You research. + writer: + role: Writer + goal: Write posts + instructions: You write. + steps: + - name: research + agent: researcher + action: "Research {{input}}" + - name: draft + agent: writer + action: "Write about {{previous_output}}" +""") + +TEAM_YAML = textwrap.dedent("""\ + framework: praisonai + topic: AI agents + roles: + researcher: + role: Researcher + goal: Research topics + backstory: You research. + tasks: + research: + description: Research {topic} + expected_output: notes + writer: + role: Writer + goal: Write posts + backstory: You write. + tasks: + write: + description: Write a post + expected_output: post +""") + + +def _invoke(*args): + from typer.testing import CliRunner + + from praisonai.cli.commands.replay import app + + return CliRunner().invoke(app, list(args)) + + +def test_cli_rerun_flow_yaml(traces_dir, tmp_path, fx, fake_llm): + y = tmp_path / "flow.yaml" + y.write_text(FLOW_YAML) + fx.write_trace(traces_dir, "run-flow01", fx.flow_events( + yaml_path=y, step_names=("research", "draft"), + outputs={"research": "RECORDED NOTES", "draft": "old post"}, + )) + result = _invoke("rerun", "run-flow01", "--from", "step:draft") + assert result.exit_code == 0, result.output + assert [name for name, _ in fake_llm] == ["writer"] # only the draft step's agent ran + assert "RECORDED NOTES" in fake_llm[0][1] + assert "reusing: step:research" in result.output + new_ids = [p.stem for p in traces_dir.glob("*.jsonl") if p.stem != "run-flow01"] + assert len(new_ids) == 1 + first = _lines(traces_dir / f"{new_ids[0]}.jsonl")[0] + assert first["data"]["forked_from"] == {"run_id": "run-flow01", "node_id": "step:draft"} + assert first["data"]["yaml_path"] == str(y.resolve()) + + +def test_cli_rerun_team_yaml_with_file_option(traces_dir, tmp_path, fx, fake_llm): + y = tmp_path / "team.yaml" + y.write_text(TEAM_YAML) + evs = fx.team_events() # recorded without a yaml_path -> --file is required + for ev in evs: # YAML team tasks have no names: node ids are positional + if ev.get("node_id") == "task:research": + ev["node_id"] = "task:0" + elif ev.get("node_id") == "task:write": + ev["node_id"] = "task:1" + fx.write_trace(traces_dir, "run-team01", evs) + + missing = _invoke("rerun", "run-team01", "--from", "task:1") + assert missing.exit_code == 1 and "--file" in missing.output + + result = _invoke("rerun", "run-team01", "--from", "task:1", "--file", str(y)) + assert result.exit_code == 0, result.output + assert [name for name, _ in fake_llm] == ["Writer"] + assert "Findings: AI is big" in fake_llm[0][1] + + +def test_cli_rerun_rejects_kind_mismatch(traces_dir, tmp_path, fx, fake_llm): + y = tmp_path / "team.yaml" + y.write_text(TEAM_YAML) + fx.write_trace(traces_dir, "run-flow01", fx.flow_events(yaml_path=y)) + result = _invoke("rerun", "run-flow01", "--from", "step:draft") + assert result.exit_code == 1 + assert "defines a team" in result.output + assert fake_llm == [] # refused before spending anything + + +def test_cli_rerun_legacy_trace_fails_clearly(traces_dir, fx): + fx.write_trace(traces_dir, "run-legacy1", fx.legacy_events()) + result = _invoke("rerun", "run-legacy1", "--from", "task:write") + assert result.exit_code == 1 + assert "schema 1.0" in result.output From 5dfac0c01ff8ee53684ae004bdd4c3923d29185d Mon Sep 17 00:00:00 2001 From: MervinPraison <454862+MervinPraison@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:32:30 +0000 Subject: [PATCH 05/12] test(replay): real agentic record -> export test (gated) RUN_REAL_KEY_TESTS=1 PRAISONAI_ALLOW_NETWORK=1 with OPENAI_API_KEY: one gpt-4o-mini call under record(live=True), then replay export --format html and --format otel on the recorded trace. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../unit/replay/test_replay_real_agentic.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/praisonai/tests/unit/replay/test_replay_real_agentic.py diff --git a/src/praisonai/tests/unit/replay/test_replay_real_agentic.py b/src/praisonai/tests/unit/replay/test_replay_real_agentic.py new file mode 100644 index 0000000000..36aae192df --- /dev/null +++ b/src/praisonai/tests/unit/replay/test_replay_real_agentic.py @@ -0,0 +1,54 @@ +"""Real agentic test (AGENTS.md ยง9.4): record a live Agent run, export it to HTML. + +Gated: RUN_REAL_KEY_TESTS=1 PRAISONAI_ALLOW_NETWORK=1 and OPENAI_API_KEY. One +cheap gpt-4o-mini call. +""" + +import json +import os + +import pytest + +pytestmark = [ + pytest.mark.skipif( + os.environ.get("RUN_REAL_KEY_TESTS") != "1" or not os.environ.get("OPENAI_API_KEY"), + reason="Real key tests disabled. Set RUN_REAL_KEY_TESTS=1 PRAISONAI_ALLOW_NETWORK=1 and OPENAI_API_KEY.", + ), + pytest.mark.network, + pytest.mark.provider_openai, +] + + +def test_record_live_agent_then_export_html(traces_dir, tmp_path): + from typer.testing import CliRunner + + from praisonaiagents import Agent + from praisonai.cli.commands.replay import app + from praisonai.replay import load_events, record + + with record(live=True) as rec: + agent = Agent(name="Greeter", instructions="You are concise.", llm="gpt-4o-mini") + answer = agent.start("Say hello in one sentence") + + print("\nAgent answer:", answer) + assert answer and isinstance(str(answer), str) + + events = load_events(rec.path) + types = [e["event_type"] for e in events] + print("Recorded", rec.run_id, "->", rec.path) + print("Event types:", types) + assert types[0] == "session_start" and types[-1] == "session_end" + assert len(events) > 2, "the agent run itself must have emitted events" + + out = tmp_path / "replay.html" + result = CliRunner().invoke(app, ["export", rec.run_id, "--format", "html", "-o", str(out)]) + print(result.output) + assert result.exit_code == 0, result.output + html = out.read_text(encoding="utf-8") + assert "window.__REPLAY_DATA__" in html and "Greeter" in html + print("HTML export:", out, f"{len(html):,} bytes") + + otlp = CliRunner().invoke(app, ["export", rec.run_id, "--format", "otel"]) + spans = json.loads(otlp.output)["resourceSpans"][0]["scopeSpans"][0]["spans"] + print("OTLP spans:", [s["name"] for s in spans]) + assert any(s["name"].startswith("invoke_agent") for s in spans) From e6f6ba9d57516da3c92160850423da3dae79ddec Mon Sep 17 00:00:00 2001 From: MervinPraison <454862+MervinPraison@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:50:27 +0000 Subject: [PATCH 06/12] fix(replay): record() stamps its run_id on every event record() passed run_id only to emitters that took it as an argument, and the schema 1.1 emitter stamps it through a run scope instead, so events outside a team or flow (session start/end, a single agent's hand-off) had no run_id. record() now enters the SDK run scope for the whole recording; nested teams and flows inherit it. Older SDKs without run scopes are unaffected. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/praisonai/praisonai/replay/recorder.py | 16 +++++++++++++++ .../unit/replay/test_replay_storage_record.py | 20 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/praisonai/praisonai/replay/recorder.py b/src/praisonai/praisonai/replay/recorder.py index 1783239312..bef498a508 100644 --- a/src/praisonai/praisonai/replay/recorder.py +++ b/src/praisonai/praisonai/replay/recorder.py @@ -92,6 +92,17 @@ def _make_emitter(sink: Any, run_id: str, full_content: bool): return ContextTraceEmitter(**kwargs) +def _enter_run_scope(run_id: str): + """Enter the SDK's run scope when it has one (schema 1.1); else ``None``.""" + try: + from praisonaiagents.trace.context_events import _run_scope + except ImportError: + return None + scope = _run_scope(run_id) + scope.__enter__() + return scope + + @contextlib.contextmanager def record( run_id: Optional[str] = None, @@ -124,6 +135,9 @@ def record( sink: Any = _ForkTagSink(writer, _forked_from) if _forked_from else writer emitter = _make_emitter(sink, run_id, bool(full_content)) token = set_context_emitter(emitter) + # Schema 1.1 SDKs stamp ``run_id`` through a run scope; nested teams and + # flows inherit it, so every event in the recording shares this id. + run_scope = _enter_run_scope(run_id) started = time.time() rec = Recording(run_id=run_id, path=writer.path, emitter=emitter, writer=writer, live=live) @@ -145,4 +159,6 @@ def record( ) finally: writer.close() + if run_scope is not None: + run_scope.__exit__(None, None, None) reset_context_emitter(token) diff --git a/src/praisonai/tests/unit/replay/test_replay_storage_record.py b/src/praisonai/tests/unit/replay/test_replay_storage_record.py index f9df2b2cea..e07d62bd3e 100644 --- a/src/praisonai/tests/unit/replay/test_replay_storage_record.py +++ b/src/praisonai/tests/unit/replay/test_replay_storage_record.py @@ -322,3 +322,23 @@ def test_save_replay_is_a_known_yaml_field(): from praisonai.config.schema import YAMLConfig assert "save_replay" in YAMLConfig.model_fields + + +def test_record_stamps_run_id_on_every_event(tmp_path, monkeypatch): + """Every event in a recording shares the recording's run_id (schema 1.1 SDKs).""" + import json + + import pytest + + ce = pytest.importorskip("praisonaiagents.trace.context_events") + if not hasattr(ce, "_run_scope"): + pytest.skip("SDK predates schema 1.1 run scopes") + monkeypatch.setenv("PRAISONAI_TRACES_DIR", str(tmp_path)) + from praisonai.replay import record + + with record(run_id="run-scope-test") as rec: + ce.get_context_emitter().agent_start("Solo", {}) + ce.get_context_emitter().agent_end("Solo", {}) + events = [json.loads(line) for line in open(rec.path)] + assert events and {e.get("run_id") for e in events} == {"run-scope-test"} + assert ce._run_id_var.get() is None # scope closed after the block From d73f4d4b8ee74cb1684958b1262a1417b0e4eb39 Mon Sep 17 00:00:00 2001 From: MervinPraison <454862+MervinPraison@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:55:07 +0000 Subject: [PATCH 07/12] fix(replay): a team YAML's map uses the ids its run emits `replay flow team.yaml` built the static graph from the YAML flow conversion, which gives step: node ids, while running the same YAML builds an AgentTeam whose events carry task:/agent: ids, so live events never landed on the map. Team YAMLs now go through the PraisonAI adapter's own builders (no LLM call); flow YAMLs and older SDKs keep the previous path. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/praisonai/praisonai/replay/yaml_run.py | 37 +++++++++++++++++++ .../tests/unit/replay/test_replay_cli.py | 25 +++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/praisonai/praisonai/replay/yaml_run.py b/src/praisonai/praisonai/replay/yaml_run.py index 1cdc9ddd24..1ad541adcf 100644 --- a/src/praisonai/praisonai/replay/yaml_run.py +++ b/src/praisonai/praisonai/replay/yaml_run.py @@ -124,6 +124,18 @@ def static_graph(yaml_path: Union[str, Path]) -> Tuple[Dict[str, Any], Any]: flow = load_flow(yaml_path) to_graph = _core_diagram("to_graph") if to_graph is not None: + # A team YAML runs as an AgentTeam (task:/agent: node ids), so build the + # same team the run builds; the flow view would give step: ids that + # never match the run's events. + if yaml_kind(yaml_path) == "team": + team = _build_team_from_yaml(yaml_path) + if team is not None: + try: + graph = to_graph(team) + if isinstance(graph, dict): + return graph, flow + except Exception: + pass try: graph = to_graph(flow) if isinstance(graph, dict): @@ -133,6 +145,31 @@ def static_graph(yaml_path: Union[str, Path]) -> Tuple[Dict[str, Any], Any]: return _fallback_graph(flow), flow +def _build_team_from_yaml(yaml_path: Union[str, Path]) -> Any: + """The ``AgentTeam`` a ``praisonai file.yaml`` run would build, without running it. + + Uses the PraisonAI adapter's own builders so task and agent ids match the run. + Returns ``None`` when the YAML can't be built offline (e.g. missing tools). + """ + import yaml + + try: + from praisonai.framework_adapters.praisonai_adapter import PraisonAIAdapter + from praisonai_code.llm.config import build_config_list + except Exception: + return None + try: + config = yaml.safe_load(Path(yaml_path).expanduser().read_text(encoding="utf-8")) or {} + adapter = PraisonAIAdapter() + model = adapter._pick_model(build_config_list()) + agents, tasks = adapter._build_agents_and_tasks( + config, config.get("topic", ""), {}, None, None, model + ) + return adapter._build_team(config, agents, tasks, model) + except Exception: + return None + + def static_mermaid(yaml_path: Union[str, Path]) -> str: """Mermaid for a YAML definition: ``graph_to_mermaid`` when available, else ``AgentFlow.to_mermaid()``.""" graph, flow = static_graph(yaml_path) diff --git a/src/praisonai/tests/unit/replay/test_replay_cli.py b/src/praisonai/tests/unit/replay/test_replay_cli.py index e89ad5d0e5..e934061c62 100644 --- a/src/praisonai/tests/unit/replay/test_replay_cli.py +++ b/src/praisonai/tests/unit/replay/test_replay_cli.py @@ -300,3 +300,28 @@ def terminate(self): proc = Running() assert _wait_for_server(proc) == 0 assert proc.terminated + + +def test_team_yaml_static_graph_uses_the_run_ids(tmp_path): + """A team YAML's map is built from the same AgentTeam a run builds (task:/agent: ids).""" + import pytest + + from praisonai.replay import yaml_run + + if yaml_run._core_diagram("to_graph") is None: + pytest.skip("SDK has no to_graph (pre schema 1.1)") + path = tmp_path / "team.yaml" + path.write_text( + "framework: praisonai\n" + "topic: t\n" + "roles:\n" + " researcher:\n role: Researcher\n goal: g\n backstory: b\n" + " tasks:\n research:\n description: d\n expected_output: e\n" + " writer:\n role: Writer\n goal: g\n backstory: b\n" + " tasks:\n write:\n description: d\n expected_output: e\n", + encoding="utf-8", + ) + graph, _ = yaml_run.static_graph(path) + ids = {n["id"] for n in graph["nodes"]} + assert {"agent:Researcher", "agent:Writer", "task:0", "task:1"} <= ids + assert not any(i.startswith("step:") for i in ids) From 2337d08a016c4ef2f97f936c639f342f25d1509f Mon Sep 17 00:00:00 2001 From: MervinPraison <454862+MervinPraison@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:56:42 +0000 Subject: [PATCH 08/12] fix(replay): rerun accepts the Path that record() returns plan_rerun only treated str as a trace path, so passing rec.path (a pathlib.Path) straight from record() crashed with 'PosixPath is not iterable'. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/praisonai/praisonai/replay/rerun.py | 3 ++- .../tests/unit/replay/test_replay_rerun.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/praisonai/praisonai/replay/rerun.py b/src/praisonai/praisonai/replay/rerun.py index 0b6f723ab3..0b21ceb439 100644 --- a/src/praisonai/praisonai/replay/rerun.py +++ b/src/praisonai/praisonai/replay/rerun.py @@ -24,6 +24,7 @@ from __future__ import annotations import contextlib +import os import threading from collections import OrderedDict from typing import Any, Callable, Dict, Iterator, List, Optional, Union @@ -111,7 +112,7 @@ def _recover_output(span: Dict[str, Any], events: List[Dict[str, Any]]) -> Any: def plan_rerun(events: Union[str, List[Any]], node_id: str) -> RerunPlan: """Work out which recorded outputs to reuse for a re-run from ``node_id``.""" - evs = load_events(events) if isinstance(events, str) else as_dicts(events) + evs = load_events(events) if isinstance(events, (str, os.PathLike)) else as_dicts(events) if not evs: raise RerunError("The trace is empty.") if not any(ev.get("node_id") for ev in evs): diff --git a/src/praisonai/tests/unit/replay/test_replay_rerun.py b/src/praisonai/tests/unit/replay/test_replay_rerun.py index 8c9ba1663d..ea33d6bd50 100644 --- a/src/praisonai/tests/unit/replay/test_replay_rerun.py +++ b/src/praisonai/tests/unit/replay/test_replay_rerun.py @@ -314,3 +314,22 @@ def test_cli_rerun_legacy_trace_fails_clearly(traces_dir, fx): result = _invoke("rerun", "run-legacy1", "--from", "task:write") assert result.exit_code == 1 assert "schema 1.0" in result.output + + +def test_plan_rerun_accepts_a_path_object(tmp_path): + """record() hands back a pathlib.Path; rerun must take it as-is.""" + import json + from pathlib import Path + + from praisonai.replay.rerun import plan_rerun + + events = [ + {"event_type": "session_start", "session_id": "s", "sequence_num": 0, "timestamp": 1.0, "data": {}, "run_id": "r"}, + {"event_type": "workflow_start", "session_id": "s", "sequence_num": 1, "timestamp": 1.1, "data": {"kind": "flow"}, "run_id": "r"}, + {"event_type": "step_start", "session_id": "s", "sequence_num": 2, "timestamp": 1.2, "data": {"name": "a"}, "node_id": "step:a", "run_id": "r"}, + {"event_type": "step_end", "session_id": "s", "sequence_num": 3, "timestamp": 1.3, "parent_seq": 2, "data": {"status": "completed", "output": "A"}, "node_id": "step:a", "run_id": "r"}, + {"event_type": "step_start", "session_id": "s", "sequence_num": 4, "timestamp": 1.4, "data": {"name": "b"}, "node_id": "step:b", "run_id": "r"}, + ] + path = tmp_path / "r.jsonl" + path.write_text("\n".join(json.dumps(e) for e in events) + "\n", encoding="utf-8") + assert plan_rerun(Path(path), "step:b") is not None From c90f1457458d6538f7bb861c70d7cf0cbeb22005 Mon Sep 17 00:00:00 2001 From: MervinPraison <454862+MervinPraison@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:02:13 +0000 Subject: [PATCH 09/12] fix(replay): HTML export runs aiui's renderer instead of the fallback table aiui's replay-core.js and replay.js are ES modules (export statements). Inlined as classic " + f"" for p in renderer ) else: diff --git a/src/praisonai/tests/unit/replay/test_replay_export.py b/src/praisonai/tests/unit/replay/test_replay_export.py index 145bdbae50..d4f1cf8a20 100644 --- a/src/praisonai/tests/unit/replay/test_replay_export.py +++ b/src/praisonai/tests/unit/replay/test_replay_export.py @@ -242,3 +242,18 @@ def test_redaction_keeps_numeric_token_counters(): out = redact_events([{"event_type": "llm_response", "data": {"max_tokens": 50, "token": "abc", "auth": {"x": 1}}}]) assert out[0]["data"] == {"max_tokens": 50, "token": "[REDACTED]", "auth": "[REDACTED]"} + + +def test_html_inlines_aiui_renderer_as_module_scripts(tmp_path, monkeypatch): + """aiui's renderer files use ``export``; inlined as classic scripts they never run.""" + from praisonai.replay import export + + core = tmp_path / "replay-core.js" + view = tmp_path / "replay.js" + core.write_text("export const X = 1;\n", encoding="utf-8") + view.write_text("export function render() {}\n", encoding="utf-8") + monkeypatch.setattr(export, "aiui_renderer_files", lambda: [core, view]) + events = [{"event_type": "session_start", "session_id": "s", "sequence_num": 0, "timestamp": 1.0, "data": {}}] + html = export.to_html(events) + assert html.count('