From 114b4bfc1cc7410a8107af313aa763d948d95ace Mon Sep 17 00:00:00 2001 From: muhammedrazalak Date: Thu, 23 Jul 2026 14:50:31 +0530 Subject: [PATCH 1/5] [NET-1341] update: Add before each and after each support for hooks --- netra/simulation/api.py | 40 ++++++++--- netra/simulation/hooks.py | 143 +++++++++++++++++++++++++++++++++----- 2 files changed, 153 insertions(+), 30 deletions(-) diff --git a/netra/simulation/api.py b/netra/simulation/api.py index b82f802..38a8a59 100644 --- a/netra/simulation/api.py +++ b/netra/simulation/api.py @@ -11,8 +11,10 @@ SimulationHooks, run_after, run_after_all, + run_after_each, run_before, run_before_all, + run_before_each, ) from netra.simulation.models import ConversationStatus, FileData, SimulationItem from netra.simulation.task import BaseTask @@ -73,17 +75,21 @@ def run_simulation( max_turns: Maximum conversation turns per item before aborting (default: 50). hooks: Optional :class:`~netra.simulation.hooks.SimulationHooks` - with ``before_all``, ``before``, ``after``, and ``after_all`` - callables. Scripts live entirely on the SDK side; only - lightweight metadata is forwarded to the backend for UI display. + with ``before_all``, ``before_each``, ``before``, ``after``, + ``after_each``, and ``after_all`` callables. Scripts live + entirely on the SDK side; only lightweight metadata is + forwarded to the backend for UI display. - ``before_all`` runs once before any scenario. If it raises, the entire run is marked failed and no scenarios execute. + - ``before_each`` runs before every scenario. If it raises, + that scenario is marked ``prescript_failed`` and skipped; + other scenarios continue. - ``before`` runs before each scenario. If it raises, that scenario is marked ``prescript_failed`` and skipped; other scenarios continue. - - ``after`` / ``after_all`` failures are logged but do not - affect scenario or run status. + - ``after`` / ``after_each`` / ``after_all`` failures are + logged but do not affect scenario or run status. Returns: Dictionary with simulation results, or None on failure. @@ -156,11 +162,12 @@ async def run_before_and_generate_first_turn( loop: asyncio.AbstractEventLoop, executor: concurrent.futures.ThreadPoolExecutor, ) -> Optional[SimulationItem]: - """Execute the before hook and generate the first turn for a single item. + """Execute the before_each/before hooks and generate the first turn for a single item. - The before hook runs on the event loop (it may be async). The - ``generate_first_turn`` HTTP call is offloaded to *executor* so - it does not block the loop. + The before_each hook runs for every item, then the item-specific + before hook runs (if registered). Both run on the event loop (they + may be async). The ``generate_first_turn`` HTTP call is offloaded + to *executor* so it does not block the loop. Returns: SimulationItem if successful, None if failed (failure recorded in failed_items). @@ -169,9 +176,14 @@ async def run_before_and_generate_first_turn( dataset_item_id = item["dataset_item_id"] has_before_hook = hooks and hooks.before and dataset_item_id in hooks.before - if has_before_hook: + has_before_each_hook = hooks and hooks.before_each is not None + + if has_before_each_hook or has_before_hook: try: - item_context = await run_before(hooks, dataset_item_id, shared_context) + # before_each runs first for every item + item_context = await run_before_each(hooks, dataset_item_id, shared_context) + # then item-specific before hook (receives merged context from before_each) + item_context = await run_before(hooks, dataset_item_id, item_context) setup_contexts[run_item_id] = item_context except Exception as exc: error_msg = f"before hook failed: {exc}" @@ -195,6 +207,7 @@ async def run_before_and_generate_first_turn( } failed_items.append(item_result) await run_after(hooks, dataset_item_id, item_result, shared_context) + await run_after_each(hooks, dataset_item_id, item_result, shared_context) return None else: setup_contexts[run_item_id] = shared_context @@ -223,6 +236,7 @@ async def run_before_and_generate_first_turn( } failed_items.append(item_result) await run_after(hooks, dataset_item_id, item_result, setup_contexts[run_item_id]) + await run_after_each(hooks, dataset_item_id, item_result, setup_contexts[run_item_id]) return None return sim_item @@ -455,6 +469,7 @@ async def _execute_conversation( "turn_id": turn_id, } await run_after(hooks, dataset_item_id, item_result, setup_context) + await run_after_each(hooks, dataset_item_id, item_result, setup_context) return item_result if response.decision == ConversationStatus.STOP: @@ -470,6 +485,7 @@ async def _execute_conversation( "final_turn_id": turn_id, } await run_after(hooks, dataset_item_id, item_result, setup_context) + await run_after_each(hooks, dataset_item_id, item_result, setup_context) return item_result message = response.next_user_message # type:ignore[assignment] @@ -493,6 +509,7 @@ async def _execute_conversation( "turn_id": turn_id, } await run_after(hooks, dataset_item_id, item_result, setup_context) + await run_after_each(hooks, dataset_item_id, item_result, setup_context) return item_result error_msg = f"Exceeded maximum turns ({max_turns}) for run_item_id={run_item_id}" @@ -505,4 +522,5 @@ async def _execute_conversation( "turn_id": turn_id, } await run_after(hooks, dataset_item_id, item_result, setup_context) + await run_after_each(hooks, dataset_item_id, item_result, setup_context) return item_result diff --git a/netra/simulation/hooks.py b/netra/simulation/hooks.py index a885f3a..df88c62 100644 --- a/netra/simulation/hooks.py +++ b/netra/simulation/hooks.py @@ -5,21 +5,26 @@ Hook levels: before_all -- runs once before any scenario starts (dataset-level setup) + before_each -- runs before every scenario (common per-item setup) before -- runs before specific scenarios only (item-specific setup, keyed by dataset_item_id) after -- runs after specific scenarios only (item-specific teardown, keyed by dataset_item_id) + after_each -- runs after every scenario (common per-item teardown) after_all -- runs once after all scenarios complete (dataset-level teardown) Execution order per item: before_all() -> returns shared_context (dict | None) - before[dataset_item_id](shared_context) -> returns item_context (dict | None), if registered + before_each(shared_context) -> returns each_context (dict | None) + before[dataset_item_id](merged_context) -> returns item_context (dict | None), if registered BaseTask.run(..., setup_context) <- receives merged context after[dataset_item_id](result, setup_context), if registered + after_each(result, setup_context) after_all(results, shared_context) Failure semantics: - - before_all failure -> entire run is marked failed (prescript_failed), no scenarios run - - before failure -> that scenario is marked failed (prescript_failed), others continue - - after / after_all failures are logged as warnings and do not affect run/scenario status + - before_all failure -> entire run is marked failed (prescript_failed), no scenarios run + - before_each failure -> that scenario is marked failed (prescript_failed), others continue + - before failure -> that scenario is marked failed (prescript_failed), others continue + - after / after_each / after_all failures are logged as warnings and do not affect run/scenario status """ import asyncio @@ -32,8 +37,10 @@ # Hook function type aliases BeforeAllFn = Callable[[], Any | Awaitable[Any]] +BeforeEachFn = Callable[..., Any | Awaitable[Any]] BeforeFn = Callable[..., Any | Awaitable[Any]] AfterFn = Callable[..., Any | Awaitable[Any]] +AfterEachFn = Callable[..., Any | Awaitable[Any]] AfterAllFn = Callable[..., Any | Awaitable[Any]] @@ -46,18 +53,23 @@ class SimulationHooks: Attributes: before_all: Called once before any scenario starts. May return a - ``dict`` that is forwarded to ``before`` hooks and every - ``BaseTask.run()`` call as ``setup_context``. + ``dict`` that is forwarded to ``before_each``/``before`` hooks and + every ``BaseTask.run()`` call as ``setup_context``. + before_each: Called before every scenario. Receives ``shared_context`` + and may return a ``dict`` that is merged with ``shared_context`` + before being passed to the item-specific ``before`` hook (if any) + and ultimately to ``BaseTask.run()`` as ``setup_context``. before: Dict mapping ``dataset_item_id`` to hook functions. Each function - receives ``shared_context`` and may return a ``dict`` that is merged - with ``shared_context`` and passed as ``setup_context`` to - ``BaseTask.run()`` for that specific scenario. Only called for - specific items that have registered hooks. + receives the merged context (from ``before_all`` + ``before_each``) + and may return a ``dict`` that is merged into ``setup_context`` + for that specific scenario. Only called for specific items that + have registered hooks. after: Dict mapping ``dataset_item_id`` to hook functions. Each function receives the result ``dict`` from the conversation loop and - ``setup_context`` (merged ``before_all`` + item ``before``). Only - called for specific items that have registered hooks. Return value - is ignored. + ``setup_context``. Only called for specific items that have + registered hooks. Return value is ignored. + after_each: Called after every scenario. Receives the item result and + ``setup_context``. Return value is ignored. after_all: Called once after all scenarios finish. Receives the aggregated results ``dict`` and ``shared_context``. Return value is ignored. @@ -68,38 +80,52 @@ def setup(): employee = create_employee() return {"employee_id": employee.id} + def setup_each(shared_context): + # Runs before every scenario + token = get_fresh_token() + return {"auth_token": token} + def setup_refund_item(shared_context): # Only for refund scenario token = login(shared_context["employee_id"]) return {"refund_account": "12345", "token": token} def teardown_refund_item(result, setup_context): - # Cleanup only for refund scenario (uses item context from before) + # Cleanup only for refund scenario logout(setup_context.get("token")) cleanup_refund(setup_context.get("refund_account")) + def teardown_each(result, setup_context): + # Runs after every scenario + invalidate_token(setup_context.get("auth_token")) + def teardown(results, shared_context): delete_employee(shared_context["employee_id"]) hooks = SimulationHooks( before_all=setup, + before_each=setup_each, before={"refund-scenario-id": setup_refund_item}, after={"refund-scenario-id": teardown_refund_item}, + after_each=teardown_each, after_all=teardown, ) """ before_all: Optional[BeforeAllFn] = field(default=None) + before_each: Optional[BeforeEachFn] = field(default=None) before: Optional[dict[str, BeforeFn]] = field(default=None) after: Optional[dict[str, AfterFn]] = field(default=None) + after_each: Optional[AfterEachFn] = field(default=None) after_all: Optional[AfterAllFn] = field(default=None) def describe(self) -> dict[str, Any]: """Return a metadata dict suitable for sending to the backend. - Run-level hooks (``beforeAll`` / ``afterAll``) are stored on the test run. - Item-level hooks (``before`` / ``after``) are sent under ``items`` keyed by - ``datasetItemId`` and stored on each matching test run item. + Run-level hooks (``beforeAll`` / ``beforeEach`` / ``afterEach`` / ``afterAll``) + are stored on the test run. Item-level hooks (``before`` / ``after``) are + sent under ``items`` keyed by ``datasetItemId`` and stored on each matching + test run item. """ def _desc(fn: Optional[Callable[..., Any]]) -> Optional[dict[str, Any]]: @@ -114,9 +140,15 @@ def _desc(fn: Optional[Callable[..., Any]]) -> Optional[dict[str, Any]]: payload: dict[str, Any] = {} before_all = _desc(self.before_all) + before_each = _desc(self.before_each) + after_each = _desc(self.after_each) after_all = _desc(self.after_all) if before_all: payload["beforeAll"] = before_all + if before_each: + payload["beforeEach"] = before_each + if after_each: + payload["afterEach"] = after_each if after_all: payload["afterAll"] = after_all @@ -196,7 +228,8 @@ async def run_before( Args: hooks: The :class:`SimulationHooks` instance, or ``None``. dataset_item_id: The stable identifier from the dataset item. - shared_context: The dict returned by ``before_all``, or ``None``. + shared_context: The dict returned by ``before_all`` (possibly merged + with ``before_each`` output), or ``None``. Returns: A merged context dict (``shared_context`` + item-specific ``before`` result), @@ -225,6 +258,46 @@ async def run_before( return shared_context +async def run_before_each( + hooks: Optional[SimulationHooks], + dataset_item_id: str, + shared_context: Optional[dict[str, Any]], +) -> Optional[dict[str, Any]]: + """Execute the ``before_each`` hook for a single scenario. + + Unlike ``before`` (which is item-specific), ``before_each`` runs for + every dataset item. + + Args: + hooks: The :class:`SimulationHooks` instance, or ``None``. + dataset_item_id: The stable identifier from the dataset item. + shared_context: The dict returned by ``before_all``, or ``None``. + + Returns: + A merged context dict (``shared_context`` + ``before_each`` result), + or ``shared_context`` unchanged when no hook is configured. + + Raises: + Exception: Re-raises any exception so the caller can mark the + scenario as ``prescript_failed``. + """ + if hooks is None or hooks.before_each is None: + return shared_context + + logger.info("netra.simulation: running before_each hook for dataset_item_id=%s", dataset_item_id) + result = await _call_hook(hooks.before_each, shared_context) + + base = dict(shared_context or {}) + if result is not None and isinstance(result, dict): + base.update(result) + elif result is not None: + logger.warning( + "netra.simulation: before_each hook returned %s (expected dict or None); ignoring value", + type(result).__name__, + ) + return base or None + + async def run_after( hooks: Optional[SimulationHooks], dataset_item_id: str, @@ -243,7 +316,7 @@ async def run_after( dataset_item_id: The stable identifier from the dataset item. item_result: The result dict from the conversation loop (or error result if ``before`` failed). - setup_context: Merged context from ``before_all`` + item ``before`` + setup_context: Merged context from ``before_all`` + ``before_each`` + item ``before`` (same dict passed to ``BaseTask.run``). When ``before`` failed, this is typically the ``before_all`` shared context only. """ @@ -261,6 +334,38 @@ async def run_after( ) +async def run_after_each( + hooks: Optional[SimulationHooks], + dataset_item_id: str, + item_result: dict[str, Any], + setup_context: Optional[dict[str, Any]], +) -> None: + """Execute the ``after_each`` hook for a single scenario (best-effort). + + Unlike ``after`` (which is item-specific), ``after_each`` runs for every + dataset item. Exceptions are caught and logged; they do not affect the + scenario status. + + Args: + hooks: The :class:`SimulationHooks` instance, or ``None``. + dataset_item_id: The stable identifier from the dataset item. + item_result: The result dict from the conversation loop. + setup_context: Merged context passed to ``BaseTask.run``. + """ + if hooks is None or hooks.after_each is None: + return + + logger.info("netra.simulation: running after_each hook for dataset_item_id=%s", dataset_item_id) + try: + await _call_hook(hooks.after_each, item_result, setup_context) + except Exception: + logger.warning( + "netra.simulation: after_each hook raised an exception for dataset_item_id=%s (ignored)", + dataset_item_id, + exc_info=True, + ) + + async def run_after_all( hooks: Optional[SimulationHooks], results: dict[str, Any], From 4560f6657a83b49d24192a24e2a36049fdac5de8 Mon Sep 17 00:00:00 2001 From: muhammedrazalak Date: Thu, 23 Jul 2026 15:19:34 +0530 Subject: [PATCH 2/5] [NET-1341] chore: Resolve comments --- netra/simulation/api.py | 15 +++++++++------ netra/simulation/hooks.py | 5 +++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/netra/simulation/api.py b/netra/simulation/api.py index 38a8a59..8e488c6 100644 --- a/netra/simulation/api.py +++ b/netra/simulation/api.py @@ -178,13 +178,16 @@ async def run_before_and_generate_first_turn( has_before_hook = hooks and hooks.before and dataset_item_id in hooks.before has_before_each_hook = hooks and hooks.before_each is not None + # Track the furthest successfully built context so teardown can + # clean up anything before_each created even if before fails. + setup_context: Optional[dict[str, Any]] = shared_context if has_before_each_hook or has_before_hook: try: # before_each runs first for every item - item_context = await run_before_each(hooks, dataset_item_id, shared_context) + setup_context = await run_before_each(hooks, dataset_item_id, setup_context) # then item-specific before hook (receives merged context from before_each) - item_context = await run_before(hooks, dataset_item_id, item_context) - setup_contexts[run_item_id] = item_context + setup_context = await run_before(hooks, dataset_item_id, setup_context) + setup_contexts[run_item_id] = setup_context except Exception as exc: error_msg = f"before hook failed: {exc}" logger.error( @@ -206,11 +209,11 @@ async def run_before_and_generate_first_turn( "error": error_msg, } failed_items.append(item_result) - await run_after(hooks, dataset_item_id, item_result, shared_context) - await run_after_each(hooks, dataset_item_id, item_result, shared_context) + await run_after(hooks, dataset_item_id, item_result, setup_context) + await run_after_each(hooks, dataset_item_id, item_result, setup_context) return None else: - setup_contexts[run_item_id] = shared_context + setup_contexts[run_item_id] = setup_context sim_item = await loop.run_in_executor( executor, diff --git a/netra/simulation/hooks.py b/netra/simulation/hooks.py index df88c62..ce8c5a0 100644 --- a/netra/simulation/hooks.py +++ b/netra/simulation/hooks.py @@ -317,8 +317,9 @@ async def run_after( item_result: The result dict from the conversation loop (or error result if ``before`` failed). setup_context: Merged context from ``before_all`` + ``before_each`` + item ``before`` - (same dict passed to ``BaseTask.run``). When ``before`` failed, - this is typically the ``before_all`` shared context only. + (same dict passed to ``BaseTask.run``). When a before hook fails, + this is the furthest successfully built context (e.g. ``before_all`` + only, or ``before_all`` + ``before_each`` if ``before`` failed). """ # Execute item-specific after hook (only if registered for this item) if hooks and hooks.after and dataset_item_id in hooks.after: From a6351c88d4e9938309d83560426f9dbcaa90f70e Mon Sep 17 00:00:00 2001 From: muhammedrazalak Date: Fri, 24 Jul 2026 22:20:55 +0530 Subject: [PATCH 3/5] chore: Resolve comments --- netra/simulation/api.py | 43 ++++++++++++++++------------------------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/netra/simulation/api.py b/netra/simulation/api.py index 8e488c6..73e18b0 100644 --- a/netra/simulation/api.py +++ b/netra/simulation/api.py @@ -157,21 +157,17 @@ def run_simulation( setup_contexts: dict[str, Optional[dict[str, Any]]] = {} failed_items: list[dict[str, Any]] = [] - async def run_before_and_generate_first_turn( - item: dict[str, str], - loop: asyncio.AbstractEventLoop, - executor: concurrent.futures.ThreadPoolExecutor, - ) -> Optional[SimulationItem]: - """Execute the before_each/before hooks and generate the first turn for a single item. - - The before_each hook runs for every item, then the item-specific - before hook runs (if registered). Both run on the event loop (they - may be async). The ``generate_first_turn`` HTTP call is offloaded - to *executor* so it does not block the loop. - - Returns: - SimulationItem if successful, None if failed (failure recorded in failed_items). + def _setup_item_in_thread(item: dict[str, str]) -> Optional[SimulationItem]: + """Run hooks + first-turn generation for one item in a dedicated thread. + + Each thread gets its own event loop via ``run_async_safely`` so + sync and async hooks both work without blocking the orchestrator. + Concurrency is bounded by the enclosing ThreadPoolExecutor. """ + return run_async_safely(_setup_item_async(item)) + + async def _setup_item_async(item: dict[str, str]) -> Optional[SimulationItem]: + """Async body executed inside its own event loop per thread.""" run_item_id = item["test_run_item_id"] dataset_item_id = item["dataset_item_id"] @@ -183,9 +179,7 @@ async def run_before_and_generate_first_turn( setup_context: Optional[dict[str, Any]] = shared_context if has_before_each_hook or has_before_hook: try: - # before_each runs first for every item setup_context = await run_before_each(hooks, dataset_item_id, setup_context) - # then item-specific before hook (receives merged context from before_each) setup_context = await run_before(hooks, dataset_item_id, setup_context) setup_contexts[run_item_id] = setup_context except Exception as exc: @@ -215,11 +209,9 @@ async def run_before_and_generate_first_turn( else: setup_contexts[run_item_id] = setup_context - sim_item = await loop.run_in_executor( - executor, - self._client.generate_first_turn, - run_id, - run_item_id, + sim_item = self._client.generate_first_turn( + run_id=run_id, + run_item_id=run_item_id, ) if sim_item is None: logger.warning( @@ -243,14 +235,13 @@ async def run_before_and_generate_first_turn( return None return sim_item - async def _run_all_before_and_first_turns() -> list[Optional[SimulationItem]]: + async def _run_all_setup() -> list[Optional[SimulationItem]]: loop = asyncio.get_running_loop() with concurrent.futures.ThreadPoolExecutor(max_workers=max_concurrency) as executor: - return list( - await asyncio.gather(*[run_before_and_generate_first_turn(item, loop, executor) for item in items]) - ) + futures = [loop.run_in_executor(executor, _setup_item_in_thread, item) for item in items] + return list(await asyncio.gather(*futures)) - setup_results = run_async_safely(_run_all_before_and_first_turns()) + setup_results = run_async_safely(_run_all_setup()) simulation_items = [item for item in setup_results if item is not None] if not simulation_items: From bb4147e039af126d5d7da2012102b90e326e1a3a Mon Sep 17 00:00:00 2001 From: muhammedrazalak Date: Mon, 27 Jul 2026 17:26:51 +0530 Subject: [PATCH 4/5] [NET-1341] fix: Change description extraction logic from docstring to description argument --- netra/simulation/hooks.py | 24 +++++++++++++++------ tests/test_simulation.py | 45 +++++++++++++++++++++++++++++++++------ 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/netra/simulation/hooks.py b/netra/simulation/hooks.py index ce8c5a0..4492dbd 100644 --- a/netra/simulation/hooks.py +++ b/netra/simulation/hooks.py @@ -28,7 +28,6 @@ """ import asyncio -import inspect import logging from dataclasses import dataclass, field from typing import Any, Awaitable, Callable, Optional @@ -43,6 +42,8 @@ AfterEachFn = Callable[..., Any | Awaitable[Any]] AfterAllFn = Callable[..., Any | Awaitable[Any]] +_DESCRIPTION_MAX_LEN = 200 + @dataclass class SimulationHooks: @@ -79,28 +80,30 @@ class SimulationHooks: def setup(): employee = create_employee() return {"employee_id": employee.id} + setup.description = "Create a test employee before any scenario runs." def setup_each(shared_context): - # Runs before every scenario token = get_fresh_token() return {"auth_token": token} + setup_each.description = "Obtain a fresh auth token before every scenario." def setup_refund_item(shared_context): - # Only for refund scenario token = login(shared_context["employee_id"]) return {"refund_account": "12345", "token": token} + setup_refund_item.description = "Create a refund account for the refund scenario only." def teardown_refund_item(result, setup_context): - # Cleanup only for refund scenario logout(setup_context.get("token")) cleanup_refund(setup_context.get("refund_account")) + teardown_refund_item.description = "Delete the refund account after the refund scenario." def teardown_each(result, setup_context): - # Runs after every scenario invalidate_token(setup_context.get("auth_token")) + teardown_each.description = "Invalidate the auth token after every scenario." def teardown(results, shared_context): delete_employee(shared_context["employee_id"]) + teardown.description = "Delete the test employee once all scenarios finish." hooks = SimulationHooks( before_all=setup, @@ -126,16 +129,23 @@ def describe(self) -> dict[str, Any]: are stored on the test run. Item-level hooks (``before`` / ``after``) are sent under ``items`` keyed by ``datasetItemId`` and stored on each matching test run item. + + Descriptions come from an explicit ``.description`` attribute on each + hook function, matching the TypeScript SDK. """ def _desc(fn: Optional[Callable[..., Any]]) -> Optional[dict[str, Any]]: if fn is None: return None - doc = inspect.getdoc(fn) + # Prefer an explicit `.description` property (same as the TS SDK). + explicit = getattr(fn, "description", None) + description = ( + str(explicit)[:_DESCRIPTION_MAX_LEN] if isinstance(explicit, str) and explicit.strip() else None + ) return { "configured": True, "name": getattr(fn, "__name__", None), - "description": doc[:200] if doc else None, + "description": description, } payload: dict[str, Any] = {} diff --git a/tests/test_simulation.py b/tests/test_simulation.py index d4ab8b2..a53df26 100644 --- a/tests/test_simulation.py +++ b/tests/test_simulation.py @@ -854,18 +854,24 @@ def test_hooks_describe(self) -> None: from netra.simulation.hooks import SimulationHooks def setup_all() -> dict: - """Setup shared resources.""" return {} + setup_all.description = "Setup shared resources." # type: ignore[attr-defined] + def setup_refund(shared_context: Optional[dict]) -> dict: - """Setup refund scenario.""" return {} + setup_refund.description = "Setup refund scenario." # type: ignore[attr-defined] + def teardown_refund(result: dict, setup_context: Optional[dict]) -> None: - """Teardown refund scenario.""" + pass + + teardown_refund.description = "Teardown refund scenario." # type: ignore[attr-defined] def teardown_all(results: dict, shared_context: Optional[dict]) -> None: - """Teardown shared resources.""" + pass + + teardown_all.description = "Teardown shared resources." # type: ignore[attr-defined] hooks = SimulationHooks( before_all=setup_all, @@ -897,6 +903,33 @@ def teardown_all(results: dict, shared_context: Optional[dict]) -> None: assert item["after"]["name"] == "teardown_refund" assert item["after"]["description"] == "Teardown refund scenario." + def test_hooks_describe_no_description(self) -> None: + """Hooks without an explicit .description produce None.""" + from netra.simulation.hooks import SimulationHooks + + def setup_all() -> dict: + return {} + + hooks = SimulationHooks(before_all=setup_all) + meta = hooks.describe() + + assert meta["beforeAll"]["name"] == "setup_all" + assert meta["beforeAll"]["description"] is None + + def test_hooks_describe_truncates_description(self) -> None: + """Explicit descriptions are truncated to 200 characters.""" + from netra.simulation.hooks import SimulationHooks + + def setup_all() -> dict: + return {} + + setup_all.description = "x" * 250 # type: ignore[attr-defined] + + hooks = SimulationHooks(before_all=setup_all) + meta = hooks.describe() + + assert meta["beforeAll"]["description"] == "x" * 200 + def test_hooks_describe_empty(self) -> None: """Test that hooks.describe() returns empty dict when no hooks configured.""" from netra.simulation.hooks import SimulationHooks @@ -911,11 +944,10 @@ def test_hooks_describe_multiple_items(self) -> None: from netra.simulation.hooks import SimulationHooks def setup_scenario(shared_context: Optional[dict]) -> dict: - """Setup for any scenario.""" return {} def teardown_scenario(result: dict, setup_context: Optional[dict]) -> None: - """Teardown for any scenario.""" + pass hooks = SimulationHooks( before={ @@ -937,6 +969,7 @@ def teardown_scenario(result: dict, setup_context: Optional[dict]) -> None: for item_id in ("item-1", "item-2"): assert by_id[item_id]["before"]["name"] == "setup_scenario" + assert by_id[item_id]["before"]["description"] is None assert by_id[item_id]["after"]["name"] == "teardown_scenario" # item-3 has before only From 1bd401639bb93e4d56e40270e835212862bcb57c Mon Sep 17 00:00:00 2001 From: muhammedrazalak Date: Wed, 29 Jul 2026 17:04:13 +0530 Subject: [PATCH 5/5] [NET-1397] feat: Add postscript failed status in the lifecycle hooks --- CHANGELOG.md | 6 +++ netra/simulation/api.py | 84 ++++++++++++++++++++++++++++++-------- netra/simulation/client.py | 4 +- netra/simulation/hooks.py | 59 +++++++++++--------------- 4 files changed, 101 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef615a7..b822d33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ The format is based on Keep a Changelog and this project adheres to Semantic Ver - **Refactor stream wrapper architecture to use callback injection** - `stream_utils` is now a pure utility module with no Netra-internal imports. The commit logic (serialize and set attribute on root span) is injected as a callback from `SessionManager`, eliminating the circular dependency between `stream_utils` and `SessionManager`. +## [0.1.97] - + +- **Add simulation lifecycle hooks** - Prescript/postscript support for multi-turn simulations via `SimulationHooks` (`before_all`, `before`, `after`, `after_all`). Hooks can return setup context passed into `BaseTask.run`, and the run uses a two-phase initialize / first-turn flow so hooks execute before any LLM spend. `before_all` failure aborts the run as `prescript_failed`; item `before` failure marks only that scenario; `after` / `after_all` failures are logged and do not affect status. +- **Add simulation `before_each` / `after_each` hooks** - Per-item lifecycle hooks that run for every dataset item. Execution order is `before_all` → `before_each` → item-specific `before` → task → item-specific `after` → `after_each` → `after_all`. +- **Use explicit `.description` for simulation hook metadata** - Hook descriptions sent to the backend now come from an explicit `.description` attribute on each hook function (aligned with the TypeScript SDK), instead of reading Python docstrings. + ## [0.1.96] - 2026-07-23 - **Reparent children of blocked root instruments instead of dropping the subtree** - When an instrumentation is not allowed to emit root-level spans, its children are now re-parented onto the nearest valid ancestor rather than dropping the entire subtree, so downstream spans are preserved. diff --git a/netra/simulation/api.py b/netra/simulation/api.py index 73e18b0..ee905ed 100644 --- a/netra/simulation/api.py +++ b/netra/simulation/api.py @@ -88,8 +88,10 @@ def run_simulation( - ``before`` runs before each scenario. If it raises, that scenario is marked ``prescript_failed`` and skipped; other scenarios continue. - - ``after`` / ``after_each`` / ``after_all`` failures are - logged but do not affect scenario or run status. + - ``after`` / ``after_each`` failures mark that scenario as + ``postscript_failed``; evaluations continue normally. + - ``after_all`` failure marks all scenarios as + ``postscript_failed``; evaluations continue normally. Returns: Dictionary with simulation results, or None on failure. @@ -203,8 +205,7 @@ async def _setup_item_async(item: dict[str, str]) -> Optional[SimulationItem]: "error": error_msg, } failed_items.append(item_result) - await run_after(hooks, dataset_item_id, item_result, setup_context) - await run_after_each(hooks, dataset_item_id, item_result, setup_context) + await self._run_after_hooks(run_id, run_item_id, dataset_item_id, hooks, item_result, setup_context) return None else: setup_contexts[run_item_id] = setup_context @@ -230,8 +231,9 @@ async def _setup_item_async(item: dict[str, str]) -> Optional[SimulationItem]: "error": "Failed to generate first user message", } failed_items.append(item_result) - await run_after(hooks, dataset_item_id, item_result, setup_contexts[run_item_id]) - await run_after_each(hooks, dataset_item_id, item_result, setup_contexts[run_item_id]) + await self._run_after_hooks( + run_id, run_item_id, dataset_item_id, hooks, item_result, setup_contexts[run_item_id] + ) return None return sim_item @@ -252,7 +254,18 @@ async def _run_all_setup() -> list[Optional[SimulationItem]]: "failed": failed_items, "total_items": len(items), } - run_async_safely(run_after_all(hooks, results, shared_context)) + try: + run_async_safely(run_after_all(hooks, results, shared_context)) + except Exception as exc: + error_msg = f"after_all hook failed: {exc}" + logger.error("%s: %s", LOG_PREFIX, error_msg, exc_info=True) + for item in items: + self._client.report_failure( + run_id=run_id, + run_item_id=item["test_run_item_id"], + error=error_msg, + status="postscript_failed", + ) self._client.post_run_status(run_id, "completed") return results @@ -379,7 +392,21 @@ async def process_item(run_item: SimulationItem) -> None: results.setdefault("failed", []).extend(setup_failed_items) # --- after_all --- - await run_after_all(hooks, results, shared_context) + try: + await run_after_all(hooks, results, shared_context) + except Exception as exc: + error_msg = f"after_all hook failed: {exc}" + logger.error("%s: %s", LOG_PREFIX, error_msg, exc_info=True) + all_item_ids = [item.run_item_id for item in simulation_items] + if setup_failed_items: + all_item_ids.extend(item["run_item_id"] for item in setup_failed_items if "run_item_id" in item) + for rid in all_item_ids: + self._client.report_failure( + run_id=run_id, + run_item_id=rid, + error=error_msg, + status="postscript_failed", + ) logger.info( "%s: Completed=%d, Failed=%d", @@ -389,6 +416,35 @@ async def process_item(run_item: SimulationItem) -> None: ) return results + async def _run_after_hooks( + self, + run_id: str, + run_item_id: str, + dataset_item_id: str, + hooks: Optional[SimulationHooks], + item_result: dict[str, Any], + setup_context: Optional[dict[str, Any]], + ) -> None: + """Run after/after_each hooks and report postscript_failed on error.""" + try: + await run_after(hooks, dataset_item_id, item_result, setup_context) + await run_after_each(hooks, dataset_item_id, item_result, setup_context) + except Exception as exc: + error_msg = f"after hook failed: {exc}" + logger.error( + "%s: %s for run_item_id=%s", + LOG_PREFIX, + error_msg, + run_item_id, + exc_info=True, + ) + self._client.report_failure( + run_id=run_id, + run_item_id=run_item_id, + error=error_msg, + status="postscript_failed", + ) + async def _execute_conversation( self, run_id: str, @@ -462,8 +518,7 @@ async def _execute_conversation( "error": error_msg, "turn_id": turn_id, } - await run_after(hooks, dataset_item_id, item_result, setup_context) - await run_after_each(hooks, dataset_item_id, item_result, setup_context) + await self._run_after_hooks(run_id, run_item_id, dataset_item_id, hooks, item_result, setup_context) return item_result if response.decision == ConversationStatus.STOP: @@ -478,8 +533,7 @@ async def _execute_conversation( "success": True, "final_turn_id": turn_id, } - await run_after(hooks, dataset_item_id, item_result, setup_context) - await run_after_each(hooks, dataset_item_id, item_result, setup_context) + await self._run_after_hooks(run_id, run_item_id, dataset_item_id, hooks, item_result, setup_context) return item_result message = response.next_user_message # type:ignore[assignment] @@ -502,8 +556,7 @@ async def _execute_conversation( "error": error_msg, "turn_id": turn_id, } - await run_after(hooks, dataset_item_id, item_result, setup_context) - await run_after_each(hooks, dataset_item_id, item_result, setup_context) + await self._run_after_hooks(run_id, run_item_id, dataset_item_id, hooks, item_result, setup_context) return item_result error_msg = f"Exceeded maximum turns ({max_turns}) for run_item_id={run_item_id}" @@ -515,6 +568,5 @@ async def _execute_conversation( "error": error_msg, "turn_id": turn_id, } - await run_after(hooks, dataset_item_id, item_result, setup_context) - await run_after_each(hooks, dataset_item_id, item_result, setup_context) + await self._run_after_hooks(run_id, run_item_id, dataset_item_id, hooks, item_result, setup_context) return item_result diff --git a/netra/simulation/client.py b/netra/simulation/client.py index aabb429..75c38ff 100644 --- a/netra/simulation/client.py +++ b/netra/simulation/client.py @@ -279,7 +279,9 @@ def report_failure( error: Error message describing the failure. status: The run status to set on the item. Use ``"prescript_failed"`` when the failure originated from a ``before_all`` or ``before`` - hook. Defaults to ``"failed"``. + hook, or ``"postscript_failed"`` when the failure originated + from an ``after``, ``after_each``, or ``after_all`` hook. + Defaults to ``"failed"``. """ if not self._ensure_client(): return diff --git a/netra/simulation/hooks.py b/netra/simulation/hooks.py index 4492dbd..9ed5628 100644 --- a/netra/simulation/hooks.py +++ b/netra/simulation/hooks.py @@ -24,7 +24,9 @@ - before_all failure -> entire run is marked failed (prescript_failed), no scenarios run - before_each failure -> that scenario is marked failed (prescript_failed), others continue - before failure -> that scenario is marked failed (prescript_failed), others continue - - after / after_each / after_all failures are logged as warnings and do not affect run/scenario status + - after failure -> that scenario is marked postscript_failed; evaluations continue normally + - after_each failure -> that scenario is marked postscript_failed; evaluations continue normally + - after_all failure -> all scenarios are marked postscript_failed; evaluations continue normally """ import asyncio @@ -314,12 +316,11 @@ async def run_after( item_result: dict[str, Any], setup_context: Optional[dict[str, Any]], ) -> None: - """Execute the item-specific ``after`` hook for a single scenario (best-effort). + """Execute the item-specific ``after`` hook for a single scenario. The ``after`` hook is called regardless of whether the scenario succeeded, failed, or had its ``before`` hook fail. This ensures cleanup logic runs - even when setup fails. Exceptions are caught and logged; they do not affect - the scenario status. + even when setup fails. Args: hooks: The :class:`SimulationHooks` instance, or ``None``. @@ -330,19 +331,15 @@ async def run_after( (same dict passed to ``BaseTask.run``). When a before hook fails, this is the furthest successfully built context (e.g. ``before_all`` only, or ``before_all`` + ``before_each`` if ``before`` failed). + + Raises: + Exception: Re-raises any exception so the caller can mark the + scenario as ``postscript_failed``. """ - # Execute item-specific after hook (only if registered for this item) if hooks and hooks.after and dataset_item_id in hooks.after: logger.info("netra.simulation: running after hook for dataset_item_id=%s", dataset_item_id) - try: - item_hook = hooks.after[dataset_item_id] - await _call_hook(item_hook, item_result, setup_context) - except Exception: - logger.warning( - "netra.simulation: after hook raised an exception for dataset_item_id=%s (ignored)", - dataset_item_id, - exc_info=True, - ) + item_hook = hooks.after[dataset_item_id] + await _call_hook(item_hook, item_result, setup_context) async def run_after_each( @@ -351,30 +348,26 @@ async def run_after_each( item_result: dict[str, Any], setup_context: Optional[dict[str, Any]], ) -> None: - """Execute the ``after_each`` hook for a single scenario (best-effort). + """Execute the ``after_each`` hook for a single scenario. Unlike ``after`` (which is item-specific), ``after_each`` runs for every - dataset item. Exceptions are caught and logged; they do not affect the - scenario status. + dataset item. Args: hooks: The :class:`SimulationHooks` instance, or ``None``. dataset_item_id: The stable identifier from the dataset item. item_result: The result dict from the conversation loop. setup_context: Merged context passed to ``BaseTask.run``. + + Raises: + Exception: Re-raises any exception so the caller can mark the + scenario as ``postscript_failed``. """ if hooks is None or hooks.after_each is None: return logger.info("netra.simulation: running after_each hook for dataset_item_id=%s", dataset_item_id) - try: - await _call_hook(hooks.after_each, item_result, setup_context) - except Exception: - logger.warning( - "netra.simulation: after_each hook raised an exception for dataset_item_id=%s (ignored)", - dataset_item_id, - exc_info=True, - ) + await _call_hook(hooks.after_each, item_result, setup_context) async def run_after_all( @@ -382,23 +375,19 @@ async def run_after_all( results: dict[str, Any], shared_context: Optional[dict[str, Any]], ) -> None: - """Execute the ``after_all`` hook (best-effort). - - Exceptions are caught and logged; they do not affect the run status. + """Execute the ``after_all`` hook. Args: hooks: The :class:`SimulationHooks` instance, or ``None``. results: The aggregated results dict from the simulation. shared_context: The dict returned by ``before_all``, or ``None``. + + Raises: + Exception: Re-raises any exception so the caller can mark all + scenarios as ``postscript_failed``. """ if hooks is None or hooks.after_all is None: return logger.info("netra.simulation: running after_all hook") - try: - await _call_hook(hooks.after_all, results, shared_context) - except Exception: - logger.warning( - "netra.simulation: after_all hook raised an exception (ignored)", - exc_info=True, - ) + await _call_hook(hooks.after_all, results, shared_context)