diff --git a/packages/narada-core/src/narada_core/actions/critic.py b/packages/narada-core/src/narada_core/actions/critic.py index 04f5850..fb5a02f 100644 --- a/packages/narada-core/src/narada_core/actions/critic.py +++ b/packages/narada-core/src/narada_core/actions/critic.py @@ -74,10 +74,39 @@ async def run_critic( if critic_action_trace_raw is not None else None ) + critic_workflow_trace = critic_content.get("workflowTrace") return CriticResult( validation_passed=validation_passed, structured_output=structured_output, usage=AgentUsage.model_validate(critic_dispatch_response["usage"]), action_trace=critic_action_trace, + workflow_trace=critic_workflow_trace, ) + + +def merge_critic_workflow_trace( + *, + workflow_trace: dict[str, Any] | None, + critic_result: CriticResult | None, +) -> dict[str, Any] | None: + critic_workflow_trace = ( + critic_result.workflow_trace if critic_result is not None else None + ) + if critic_workflow_trace is None: + return workflow_trace + + if workflow_trace is None: + return critic_workflow_trace + + children = workflow_trace.get("children") + if not isinstance(children, list): + return workflow_trace + + return { + **workflow_trace, + "children": [ + *children, + {"kind": "sub_workflow", "trace": critic_workflow_trace}, + ], + } diff --git a/packages/narada-core/src/narada_core/actions/models.py b/packages/narada-core/src/narada_core/actions/models.py index 4b22ad5..c8d7bb9 100644 --- a/packages/narada-core/src/narada_core/actions/models.py +++ b/packages/narada-core/src/narada_core/actions/models.py @@ -40,10 +40,13 @@ class StructuredOutput(BaseModel, Generic[_StructuredOutputT]): class CriticResult(BaseModel): + model_config = ConfigDict(populate_by_name=True) + validation_passed: bool structured_output: Any usage: AgentUsage action_trace: tracing_model.ActionTrace | None = None + workflow_trace: dict[str, Any] | None = Field(default=None, alias="workflowTrace") class AgentResponse(BaseModel, Generic[_StructuredOutputT]): diff --git a/packages/narada-pyodide/src/narada/window.py b/packages/narada-pyodide/src/narada/window.py index c69444b..826c5e4 100644 --- a/packages/narada-pyodide/src/narada/window.py +++ b/packages/narada-pyodide/src/narada/window.py @@ -21,7 +21,7 @@ from urllib.parse import urlencode from js import AbortController, setTimeout # type: ignore -from narada_core.actions.critic import run_critic +from narada_core.actions.critic import merge_critic_workflow_trace, run_critic from narada_core.actions.models import ( DEFAULT_HITL_TIMEOUT_SECONDS, AgenticMatchingSelectorsFinderRequest, @@ -709,10 +709,6 @@ async def agent( ) workflow_trace = response_content.get("workflowTrace") parent_request_id = self._current_parent_request_id() - # Preserve the response contract for direct callers, but avoid adding a second - # child node when the backend will stitch the child request into the parent row. - if workflow_trace is not None and parent_request_id is None: - _trace.emit_sub_workflow(workflow_trace=workflow_trace) critic_result: CriticResult | None = None if critic is not None: @@ -725,6 +721,15 @@ async def agent( time_zone=time_zone, timeout=timeout, ) + workflow_trace = merge_critic_workflow_trace( + workflow_trace=workflow_trace, + critic_result=critic_result, + ) + + # Preserve the response contract for direct callers, but avoid adding a second + # child node when the backend will stitch the child request into the parent row. + if workflow_trace is not None and parent_request_id is None: + _trace.emit_sub_workflow(workflow_trace=workflow_trace) return AgentResponse( request_id=remote_dispatch_response["requestId"], diff --git a/packages/narada-pyodide/tests/test_cloud_browser.py b/packages/narada-pyodide/tests/test_cloud_browser.py index 7098da3..b102422 100644 --- a/packages/narada-pyodide/tests/test_cloud_browser.py +++ b/packages/narada-pyodide/tests/test_cloud_browser.py @@ -450,6 +450,90 @@ async def test_window_agent_exposes_workflow_trace_alias( ] +@pytest.mark.asyncio +async def test_window_agent_emits_combined_critic_workflow_trace( + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow_trace = { + "workflowId": "main-workflow", + "workflowName": "Main Workflow", + "runtime": "gui", + "status": "success", + "startTs": 100, + "children": [], + } + critic_workflow_trace = { + "workflowId": "critic-workflow", + "workflowName": "Critic Workflow", + "runtime": "gui", + "status": "success", + "startTs": 200, + "children": [], + } + pyfetch = AsyncMock( + side_effect=[ + _FakeResponse(json_data={"requestId": "main-request-123"}), + _FakeResponse( + json_data={ + "status": "success", + "response": { + "text": "done", + "output": {"type": "text", "content": "done"}, + "workflowTrace": workflow_trace, + }, + "usage": {"actions": 0, "credits": 0}, + } + ), + _FakeResponse(json_data={"requestId": "critic-request-123"}), + _FakeResponse( + json_data={ + "status": "success", + "response": { + "text": '{"narada_validation_passed":true}', + "output": { + "type": "structured", + "content": {"narada_validation_passed": True}, + }, + "workflowTrace": critic_workflow_trace, + }, + "usage": {"actions": 0, "credits": 0}, + } + ), + ] + ) + _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) + emitted_events: list[str] = [] + monkeypatch.setattr( + sys.modules["narada._trace"], + "_narada_emit_trace_event", + emitted_events.append, + raising=False, + ) + + window = window_module.CloudBrowserWindow( + browser_window_id="browser-window-123", + session_id="session-123", + api_key="test-api-key", + ) + response = await window.agent(prompt="return a trace", critic={}) + + combined_workflow_trace = { + **workflow_trace, + "children": [{"kind": "sub_workflow", "trace": critic_workflow_trace}], + } + assert response.critic_result is not None + assert response.critic_result.workflow_trace == critic_workflow_trace + assert response.workflow_trace == combined_workflow_trace + sub_workflow_events = [ + json.loads(event) + for event in emitted_events + if json.loads(event)["kind"] == "subWorkflow" + ] + assert sub_workflow_events == [ + {"kind": "subWorkflow", "workflowTrace": combined_workflow_trace} + ] + + @pytest.mark.asyncio async def test_cloud_browser_window_dispatch_request_retries_poll_fetch_failures( monkeypatch: pytest.MonkeyPatch, diff --git a/packages/narada/src/narada/window.py b/packages/narada/src/narada/window.py index e5b963a..bd3dab7 100644 --- a/packages/narada/src/narada/window.py +++ b/packages/narada/src/narada/window.py @@ -21,7 +21,7 @@ ) import aiohttp -from narada_core.actions.critic import run_critic +from narada_core.actions.critic import merge_critic_workflow_trace, run_critic from narada_core.actions.models import ( DEFAULT_HITL_TIMEOUT_SECONDS, AgenticMatchingSelectorsFinderRequest, @@ -656,6 +656,10 @@ async def agent( time_zone=time_zone, timeout=timeout, ) + workflow_trace = merge_critic_workflow_trace( + workflow_trace=workflow_trace, + critic_result=critic_result, + ) return AgentResponse( request_id=remote_dispatch_response["requestId"], diff --git a/packages/narada/tests/test_cloud_browser.py b/packages/narada/tests/test_cloud_browser.py index 9e5f6f5..9c7b44f 100644 --- a/packages/narada/tests/test_cloud_browser.py +++ b/packages/narada/tests/test_cloud_browser.py @@ -205,3 +205,73 @@ async def test_window_agent_exposes_workflow_trace_alias( assert response.workflow_trace == workflow_trace assert response.model_dump(by_alias=True)["workflowTrace"] == workflow_trace + + +@pytest.mark.asyncio +async def test_window_agent_appends_critic_workflow_trace( + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow_trace = { + "workflowId": "main-workflow", + "workflowName": "Main Workflow", + "runtime": "gui", + "status": "success", + "startTs": 100, + "children": [], + } + critic_workflow_trace = { + "workflowId": "critic-workflow", + "workflowName": "Critic Workflow", + "runtime": "gui", + "status": "success", + "startTs": 200, + "children": [], + } + window = CloudBrowserWindow( + browser_window_id="browser-window-123", + session_id="session-123", + auth_headers={"x-api-key": "test-key"}, + ) + monkeypatch.setattr( + window, + "dispatch_request", + AsyncMock( + side_effect=[ + { + "requestId": "request-123", + "status": "success", + "response": { + "text": "done", + "output": {"type": "text", "content": "done"}, + "workflowTrace": workflow_trace, + }, + "usage": {"actions": 0, "credits": 0}, + }, + { + "requestId": "critic-request-123", + "status": "success", + "response": { + "text": '{"narada_validation_passed":true}', + "output": { + "type": "structured", + "content": {"narada_validation_passed": True}, + }, + "structuredOutput": SimpleNamespace( + narada_validation_passed=True + ), + "workflowTrace": critic_workflow_trace, + }, + "usage": {"actions": 0, "credits": 0}, + }, + ] + ), + ) + + response = await window.agent(prompt="return a trace", critic={}) + + assert response.critic_result is not None + assert response.critic_result.workflow_trace == critic_workflow_trace + assert response.workflow_trace == { + **workflow_trace, + "children": [{"kind": "sub_workflow", "trace": critic_workflow_trace}], + }