Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/narada-core/src/narada_core/actions/critic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
],
}
3 changes: 3 additions & 0 deletions packages/narada-core/src/narada_core/actions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]):
Expand Down
15 changes: 10 additions & 5 deletions packages/narada-pyodide/src/narada/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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"],
Expand Down
84 changes: 84 additions & 0 deletions packages/narada-pyodide/tests/test_cloud_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion packages/narada/src/narada/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"],
Expand Down
70 changes: 70 additions & 0 deletions packages/narada/tests/test_cloud_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}],
}
Loading