From 75aa79b7b5f37a565e937d81aad7d2cf5d9a87c4 Mon Sep 17 00:00:00 2001 From: sebzhao Date: Thu, 7 May 2026 12:50:07 -0700 Subject: [PATCH 01/13] feat: update gui custom agent fields --- packages/narada-core/src/narada_core/tracing/model.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/narada-core/src/narada_core/tracing/model.py b/packages/narada-core/src/narada_core/tracing/model.py index 0eb60e3..8376e6b 100644 --- a/packages/narada-core/src/narada_core/tracing/model.py +++ b/packages/narada-core/src/narada_core/tracing/model.py @@ -170,6 +170,7 @@ class RunCustomAgentTrace(BaseModel): workflow_name: str status: Literal["success", "error"] error_message: str | None = None + children: ActionTrace | None = None class IfTrace(BaseModel): From 98fd3f8f741e2a0ae10f0360f2998b46dac7e8fe Mon Sep 17 00:00:00 2001 From: sebzhao Date: Tue, 12 May 2026 16:13:18 -0700 Subject: [PATCH 02/13] feat: python request id inheriting --- packages/narada-core/pyproject.toml | 2 +- .../src/narada_core/actions/models.py | 14 ++++ .../narada-core/src/narada_core/models.py | 1 + .../src/narada_core/tracing/model.py | 6 ++ packages/narada-pyodide/pyproject.toml | 4 +- packages/narada-pyodide/src/narada/_trace.py | 9 +++ packages/narada-pyodide/src/narada/window.py | 81 ++++++++++++++++++- packages/narada/pyproject.toml | 2 +- uv.lock | 4 +- 9 files changed, 116 insertions(+), 7 deletions(-) diff --git a/packages/narada-core/pyproject.toml b/packages/narada-core/pyproject.toml index edadabf..8bce01f 100644 --- a/packages/narada-core/pyproject.toml +++ b/packages/narada-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "narada-core" -version = "0.0.21" +version = "0.0.22" description = "Code shared by the `narada` and `narada-pyodide` packages." license = "Apache-2.0" readme = "README.md" diff --git a/packages/narada-core/src/narada_core/actions/models.py b/packages/narada-core/src/narada_core/actions/models.py index 3b9dfbf..16afa87 100644 --- a/packages/narada-core/src/narada_core/actions/models.py +++ b/packages/narada-core/src/narada_core/actions/models.py @@ -15,6 +15,7 @@ from pydantic import ( BaseModel, Field, + RootModel, ) from narada_core.tracing import model as tracing_model @@ -387,6 +388,17 @@ class PromptForUserInputResponse(BaseModel): values_by_name: dict[str, Any] +class CallCustomAgentByPathRequest(BaseModel): + name: Literal["call_custom_agent_by_path"] = "call_custom_agent_by_path" + agent_path: str + prompt: str + input_variables: dict[str, Any] = Field(default_factory=dict) + + +class CallCustomAgentByPathResponse(RootModel[dict[str, Any]]): + pass + + class UserApprovalRequest(BaseModel): name: Literal["user_approval"] = "user_approval" step_id: str @@ -414,6 +426,7 @@ class UserApprovalResponse(BaseModel): | GetScreenshotRequest | GetUrlRequest | PromptForUserInputRequest + | CallCustomAgentByPathRequest | UserApprovalRequest ) @@ -422,3 +435,4 @@ class ExtensionActionResponse(BaseModel): status: Literal["success", "error", "aborted"] error: str | None = None data: str | None = None + workflowTrace: dict[str, Any] | None = None diff --git a/packages/narada-core/src/narada_core/models.py b/packages/narada-core/src/narada_core/models.py index 307c7a9..ffc6d0b 100644 --- a/packages/narada-core/src/narada_core/models.py +++ b/packages/narada-core/src/narada_core/models.py @@ -254,6 +254,7 @@ class RunCustomAgentTrace(TypedDict): workflow_name: str status: Literal["success", "error"] error_message: NotRequired[str] + children: NotRequired[ActionTrace] class IfTrace(TypedDict): diff --git a/packages/narada-core/src/narada_core/tracing/model.py b/packages/narada-core/src/narada_core/tracing/model.py index 8376e6b..c27e9a9 100644 --- a/packages/narada-core/src/narada_core/tracing/model.py +++ b/packages/narada-core/src/narada_core/tracing/model.py @@ -277,6 +277,11 @@ def _check_ts_ordering(self) -> PythonExtensionActionEvent: return self +class PythonSubWorkflowEvent(BaseModel): + kind: Literal["subWorkflow"] = "subWorkflow" + workflowTrace: dict[str, Any] + + class PythonSideEffectEvent(BaseModel): kind: Literal["sideEffect"] = "sideEffect" ts: int @@ -289,6 +294,7 @@ class PythonSideEffectEvent(BaseModel): | PythonStderrEvent | PythonSubAgentCallEvent | PythonExtensionActionEvent + | PythonSubWorkflowEvent | PythonSideEffectEvent, Field(discriminator="kind"), ] diff --git a/packages/narada-pyodide/pyproject.toml b/packages/narada-pyodide/pyproject.toml index 66e29bf..76a0ac2 100644 --- a/packages/narada-pyodide/pyproject.toml +++ b/packages/narada-pyodide/pyproject.toml @@ -1,14 +1,14 @@ [project] name = "narada-pyodide" -version = "0.0.49" +version = "0.0.50" description = "Pyodide-compatible Python client SDK for Narada" license = "Apache-2.0" readme = "README.md" authors = [{ name = "Narada", email = "support@narada.ai" }] requires-python = ">=3.12" dependencies = [ - "narada-core==0.0.21", + "narada-core==0.0.22", # Must be a supported version in https://pyodide.org/en/stable/usage/packages-in-pyodide.html "packaging==24.2", ] diff --git a/packages/narada-pyodide/src/narada/_trace.py b/packages/narada-pyodide/src/narada/_trace.py index 659ac88..8eef3ec 100644 --- a/packages/narada-pyodide/src/narada/_trace.py +++ b/packages/narada-pyodide/src/narada/_trace.py @@ -130,6 +130,15 @@ def emit_extension_action( emit_trace_event(event) +def emit_sub_workflow(*, workflow_trace: dict[str, Any]) -> None: + emit_trace_event( + { + "kind": "subWorkflow", + "workflowTrace": workflow_trace, + } + ) + + def emit_side_effect(*, effect_type: SideEffectType, description: str) -> None: emit_trace_event( { diff --git a/packages/narada-pyodide/src/narada/window.py b/packages/narada-pyodide/src/narada/window.py index b389c42..9d4c121 100644 --- a/packages/narada-pyodide/src/narada/window.py +++ b/packages/narada-pyodide/src/narada/window.py @@ -30,6 +30,8 @@ AgenticSelectors, AgentResponse, AgentUsage, + CallCustomAgentByPathRequest, + CallCustomAgentByPathResponse, CloseWindowRequest, CriticResult, ExtensionActionRequest, @@ -97,6 +99,20 @@ def _parent_run_ids() -> list[str]: ) +def _request_id() -> str | None: + # `_narada_request_id` is a plain string injected by the JavaScript harness that identifies + # the currently-running parent runnable's request. The SDK forwards it on extension-action + # requests so the spawned child runnable inherits the same request ID instead of the + # frontend handler minting a new one and orphaning the child run. + try: + value = _narada_request_id # noqa: F821 # pyright: ignore[reportUndefinedVariable] + except NameError: + return None + if value is None: + return None + return str(value) + + if TYPE_CHECKING: # Magic function injected by the JavaScript harness to get the current user's ID token. async def _narada_get_id_token() -> str: ... @@ -197,6 +213,16 @@ def _current_parent_run_ids(self) -> list[str] | None: """ return None + def _current_request_id(self) -> str | None: + """Returns the parent runnable's request ID to forward on extension-action requests. + + Mirrors `_current_parent_run_ids` — only requests targeting the current browser window + should inherit it. Without this, extension-action dispatches that spawn a child runnable + (e.g. `call_run_custom_agent_tool`) end up with a fresh request ID and the child run + is not linked to the parent in observability. + """ + return None + async def _get_auth_headers(self) -> dict[str, str]: return await _build_auth_headers( api_key=self._api_key, @@ -354,6 +380,51 @@ async def dispatch_request( trace_start_ms = _trace.now_ms() agent_type_str = agent.value if isinstance(agent, Agent) else str(agent) + parent_run_ids = self._current_parent_run_ids() + request_id = self._current_request_id() + if ( + isinstance(agent, str) + and agent.startswith("/") + and parent_run_ids + and request_id is not None + ): + result = await self._run_extension_action( + CallCustomAgentByPathRequest( + agent_path=agent, + prompt=prompt, + input_variables=input_variables or {}, + ), + CallCustomAgentByPathResponse, + timeout=min(timeout, 300), + ) + output_content = result.root + structured_output = ( + output_schema.model_validate(output_content) + if output_schema is not None + else None + ) + response_content: dict[str, Any] = { + "text": json.dumps(output_content), + "structuredOutput": structured_output, + "output": {"type": "structured", "content": output_content}, + } + completed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + _trace.emit_sub_agent_call( + ts_start=trace_start_ms, + agent_type=agent_type_str, + prompt=prompt, + status="success", + request_id=request_id, + ) + return { + "requestId": request_id, + "status": "success", + "response": response_content, + "createdAt": completed_at, + "completedAt": completed_at, + "usage": {"actions": 0, "credits": 0}, + } + deadline = time.monotonic() + timeout headers = await self._get_auth_headers() @@ -366,7 +437,6 @@ async def dispatch_request( "browserWindowId": self.browser_window_id, "timeZone": time_zone, } - parent_run_ids = self._current_parent_run_ids() if parent_run_ids: body["parentRunIds"] = parent_run_ids cloud_browser_session_id = self.cloud_browser_session_id @@ -945,6 +1015,9 @@ async def _run_extension_action( parent_run_ids = self._current_parent_run_ids() if parent_run_ids: body["parentRunIds"] = parent_run_ids + request_id = self._current_request_id() + if request_id is not None: + body["requestId"] = request_id if timeout is not None: body["timeout"] = timeout @@ -966,6 +1039,8 @@ async def _run_extension_action( resp_json = await fetch_response.json() response = ExtensionActionResponse.model_validate(resp_json) + if response.workflowTrace is not None: + _trace.emit_sub_workflow(workflow_trace=response.workflowTrace) if response.status == "error": raise NaradaError(response.error) if response.status == "aborted": @@ -1024,6 +1099,10 @@ def __str__(self) -> str: def _current_parent_run_ids(self) -> list[str] | None: return _parent_run_ids() + @override + def _current_request_id(self) -> str | None: + return _request_id() + class RemoteBrowserWindow(BaseBrowserWindow): def __init__( diff --git a/packages/narada/pyproject.toml b/packages/narada/pyproject.toml index 181e349..e2bcdb2 100644 --- a/packages/narada/pyproject.toml +++ b/packages/narada/pyproject.toml @@ -7,7 +7,7 @@ readme = "README.md" authors = [{ name = "Narada", email = "support@narada.ai" }] requires-python = ">=3.12" dependencies = [ - "narada-core==0.0.21", + "narada-core==0.0.22", "aiohttp>=3.12.13", "playwright>=1.53.0", "rich>=14.0.0", diff --git a/uv.lock b/uv.lock index c2bc9d9..0ccd08e 100644 --- a/uv.lock +++ b/uv.lock @@ -345,7 +345,7 @@ dev = [ [[package]] name = "narada-core" -version = "0.0.21" +version = "0.0.22" source = { editable = "packages/narada-core" } dependencies = [ { name = "pydantic" }, @@ -356,7 +356,7 @@ requires-dist = [{ name = "pydantic", specifier = "==2.12.5" }] [[package]] name = "narada-pyodide" -version = "0.0.49" +version = "0.0.50" source = { editable = "packages/narada-pyodide" } dependencies = [ { name = "narada-core" }, From 2905ff023b14075e4f7db984697906a142c04809 Mon Sep 17 00:00:00 2001 From: sebzhao Date: Tue, 12 May 2026 16:32:32 -0700 Subject: [PATCH 03/13] chore: bump ver --- packages/narada-core/pyproject.toml | 2 +- packages/narada-pyodide/pyproject.toml | 4 ++-- packages/narada/pyproject.toml | 2 +- uv.lock | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/narada-core/pyproject.toml b/packages/narada-core/pyproject.toml index 0966dab..9d3d69c 100644 --- a/packages/narada-core/pyproject.toml +++ b/packages/narada-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "narada-core" -version = "0.0.23" +version = "0.0.24" description = "Code shared by the `narada` and `narada-pyodide` packages." license = "Apache-2.0" readme = "README.md" diff --git a/packages/narada-pyodide/pyproject.toml b/packages/narada-pyodide/pyproject.toml index 84f6dce..47dd6f1 100644 --- a/packages/narada-pyodide/pyproject.toml +++ b/packages/narada-pyodide/pyproject.toml @@ -1,14 +1,14 @@ [project] name = "narada-pyodide" -version = "0.0.52" +version = "0.0.53" description = "Pyodide-compatible Python client SDK for Narada" license = "Apache-2.0" readme = "README.md" authors = [{ name = "Narada", email = "support@narada.ai" }] requires-python = ">=3.12" dependencies = [ - "narada-core==0.0.23", + "narada-core==0.0.24", # Must be a supported version in https://pyodide.org/en/stable/usage/packages-in-pyodide.html "packaging==24.2", ] diff --git a/packages/narada/pyproject.toml b/packages/narada/pyproject.toml index b47e034..a437ee9 100644 --- a/packages/narada/pyproject.toml +++ b/packages/narada/pyproject.toml @@ -7,7 +7,7 @@ readme = "README.md" authors = [{ name = "Narada", email = "support@narada.ai" }] requires-python = ">=3.12" dependencies = [ - "narada-core==0.0.23", + "narada-core==0.0.24", "aiohttp>=3.12.13", "playwright>=1.53.0", "rich>=14.0.0", diff --git a/uv.lock b/uv.lock index 47dabed..cdf92f2 100644 --- a/uv.lock +++ b/uv.lock @@ -345,7 +345,7 @@ dev = [ [[package]] name = "narada-core" -version = "0.0.23" +version = "0.0.24" source = { editable = "packages/narada-core" } dependencies = [ { name = "pydantic" }, @@ -356,7 +356,7 @@ requires-dist = [{ name = "pydantic", specifier = "==2.12.5" }] [[package]] name = "narada-pyodide" -version = "0.0.52" +version = "0.0.53" source = { editable = "packages/narada-pyodide" } dependencies = [ { name = "narada-core" }, From 34fd4fe996ce8a6ca46dc0eb595f9c41bc28928c Mon Sep 17 00:00:00 2001 From: sebzhao Date: Thu, 14 May 2026 12:08:39 -0700 Subject: [PATCH 04/13] fix: handle subagent run request id --- packages/narada-pyodide/src/narada/window.py | 164 ++++++++++++++----- 1 file changed, 119 insertions(+), 45 deletions(-) diff --git a/packages/narada-pyodide/src/narada/window.py b/packages/narada-pyodide/src/narada/window.py index a71d356..e54b5a2 100644 --- a/packages/narada-pyodide/src/narada/window.py +++ b/packages/narada-pyodide/src/narada/window.py @@ -54,6 +54,7 @@ ReadGoogleSheetRequest, ReadGoogleSheetResponse, RecordedClick, + StructuredOutput, UserApprovalRequest, UserApprovalResponse, WaitForElementRequest, @@ -395,51 +396,6 @@ async def dispatch_request( trace_start_ms = _trace.now_ms() agent_type_str = _trace_agent_type(agent) - parent_run_ids = self._current_parent_run_ids() - request_id = self._current_request_id() - if ( - isinstance(agent, str) - and agent.startswith("/") - and parent_run_ids - and request_id is not None - ): - result = await self._run_extension_action( - CallCustomAgentByPathRequest( - agent_path=agent, - prompt=prompt, - input_variables=input_variables or {}, - ), - CallCustomAgentByPathResponse, - timeout=min(timeout, 300), - ) - output_content = result.root - structured_output = ( - output_schema.model_validate(output_content) - if output_schema is not None - else None - ) - response_content: dict[str, Any] = { - "text": json.dumps(output_content), - "structuredOutput": structured_output, - "output": {"type": "structured", "content": output_content}, - } - completed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - _trace.emit_sub_agent_call( - ts_start=trace_start_ms, - agent_type=agent_type_str, - prompt=prompt, - status="success", - request_id=request_id, - ) - return { - "requestId": request_id, - "status": "success", - "response": response_content, - "createdAt": completed_at, - "completedAt": completed_at, - "usage": {"actions": 0, "credits": 0}, - } - deadline = time.monotonic() + timeout headers = await self._get_auth_headers() @@ -452,6 +408,7 @@ async def dispatch_request( "browserWindowId": self.browser_window_id, "timeZone": time_zone, } + parent_run_ids = self._current_parent_run_ids() if parent_run_ids: body["parentRunIds"] = parent_run_ids cloud_browser_session_id = self.cloud_browser_session_id @@ -617,6 +574,91 @@ async def dispatch_request( ) raise + async def _run_custom_agent_in_process( + self, + *, + agent_path: str, + prompt: str, + output_schema: type[BaseModel] | None, + input_variables: dict[str, Any] | None, + timeout: int, + ) -> AgentResponse: + """Run a custom agent identified by Agent Studio path as an in-process + child of the current runnable. + + This is the SDK equivalent of the GUI's `call_run_custom_agent_tool`: + instead of POSTing to `/remote-dispatch` (which mints a new + `remote_dispatch_request` row and a fresh request_id), we dispatch a + `call_custom_agent_by_path` extension-action so the frontend handler + runs `runCustomAgentWorkflow` under the parent's `runnableOptions` — + the child inherits the parent's `requestId` and the run shows up as + one logical execution in observability. + + Only valid when the SDK is itself executing inside a parent runnable + (i.e. `_current_request_id()` is non-None); callers route here from + `agent()`. + """ + trace_start_ms = _trace.now_ms() + agent_type_str = _trace_agent_type(agent_path) + request_id = self._current_request_id() + # `agent()` is the only caller and only routes here when the parent + # runnable has injected a request_id; the assertion documents that + # invariant for readers and type-checkers. + assert request_id is not None + + try: + result = await self._run_extension_action( + CallCustomAgentByPathRequest( + agent_path=agent_path, + prompt=prompt, + input_variables=input_variables or {}, + ), + CallCustomAgentByPathResponse, + # The extension-action handler in the frontend has its own + # internal timeout; cap ours at 300s so we don't sit longer + # than the handler can produce a response. + timeout=min(timeout, 300), + ) + except Exception as err: + _trace.emit_sub_agent_call( + ts_start=trace_start_ms, + agent_type=agent_type_str, + prompt=prompt, + status="error", + error_message=str(err), + request_id=request_id, + ) + raise + + output_content = result.root + structured_output = ( + output_schema.model_validate(output_content) + if output_schema is not None + else None + ) + + _trace.emit_sub_agent_call( + ts_start=trace_start_ms, + agent_type=agent_type_str, + prompt=prompt, + status="success", + request_id=request_id, + ) + + # Usage for a sub-workflow run is rolled up under the parent's + # request_id on the backend; surface zeros locally rather than + # double-counting in the parent's Python trace. + return AgentResponse( + request_id=request_id, + status="success", + text=json.dumps(output_content), + structured_output=structured_output, + output=StructuredOutput(type="structured", content=output_content), + usage=AgentUsage(actions=0, credits=0.0), + action_trace=None, + critic_result=None, + ) + # `reasoning` is only valid with the Core Agent. See `dispatch_request` # above for the rationale; the same overload pattern is mirrored here. @overload @@ -704,6 +746,38 @@ async def agent( timeout: int = 1000, ) -> AgentResponse: """Invokes an agent in the Narada extension side panel chat.""" + # Calling a custom agent by Agent Studio path from inside a parent + # runnable: route through the in-frontend `call_custom_agent_by_path` + # handler instead of `/remote-dispatch`, so the child workflow shares + # the parent's request_id and shows up as one logical run in + # observability. Mirrors the GUI's `call_run_custom_agent_tool` flow. + # `reasoning` and `critic` aren't meaningful for a sub-workflow (the + # child workflow defines its own steps); reject so callers don't think + # they're being applied. + if ( + isinstance(agent, str) + and agent.startswith("/") + and self._current_parent_run_ids() + and self._current_request_id() is not None + ): + if reasoning is not None: + raise ValueError( + "`reasoning` is not supported when calling a custom agent " + "by path from inside a parent workflow" + ) + if critic is not None: + raise ValueError( + "`critic` is not supported when calling a custom agent by " + "path from inside a parent workflow" + ) + return await self._run_custom_agent_in_process( + agent_path=agent, + prompt=prompt, + output_schema=output_schema, + input_variables=input_variables, + timeout=timeout, + ) + # Branch on `reasoning` so each call site binds a single, typed overload # of `dispatch_request`. The validation also lives in `dispatch_request` # itself (defense in depth + reachable when callers go straight to the From 7cc651f542ff2337400039bdb45ffbf7443f3959 Mon Sep 17 00:00:00 2001 From: sebzhao Date: Tue, 19 May 2026 15:31:54 -0700 Subject: [PATCH 05/13] fix: separate agent call to avoid remote dispatch and associate to parent request id --- .../src/narada_core/actions/models.py | 33 ++++ packages/narada-pyodide/pyproject.toml | 2 +- packages/narada-pyodide/src/narada/window.py | 143 ++++++++++++++++++ .../tests/test_cloud_browser.py | 112 ++++++++++++++ uv.lock | 2 +- 5 files changed, 290 insertions(+), 2 deletions(-) diff --git a/packages/narada-core/src/narada_core/actions/models.py b/packages/narada-core/src/narada_core/actions/models.py index 648aecd..3a6c850 100644 --- a/packages/narada-core/src/narada_core/actions/models.py +++ b/packages/narada-core/src/narada_core/actions/models.py @@ -420,6 +420,38 @@ class CallCustomAgentByPathResponse(RootModel[dict[str, Any]]): pass +class CallBuiltInAgentRequest(BaseModel): + """SDK-direct equivalent of ``call_agent_tool`` for built-in agent types + (Operator, Core, Productivity, ...). Dispatched from + ``window.agent(agent=Agent.X)`` when running inside a parent runnable so the + sub-agent inherits the parent's ``requestId`` instead of minting a new + ``remote_dispatch_request`` via ``dispatch_request``. + + ``agent_type`` is the dashboard's ``ApaAgentType`` string slug (e.g. + ``"operator"``, ``"coreAgent"``, ``"generalist"``). + """ + + name: Literal["call_built_in_agent"] = "call_built_in_agent" + agent_type: str + prompt: str + clear_chat: bool | None = None + response_format: dict[str, Any] | None = None + + +class CallBuiltInAgentResponse(BaseModel): + """JSON body returned in ``ExtensionActionResponse.data`` for + ``call_built_in_agent``. ``action_trace`` carries the same shape as the + legacy ``actionTrace`` field on ``dispatch_request`` responses, so the SDK + can reuse ``parse_action_trace`` to materialise it. + """ + + text: str + output: dict[str, Any] | None = None + action_trace: list[dict[str, Any]] | None = Field(default=None, alias="actionTrace") + + model_config = {"populate_by_name": True} + + class UserApprovalRequest(BaseModel): name: Literal["user_approval"] = "user_approval" step_id: str @@ -449,6 +481,7 @@ class UserApprovalResponse(BaseModel): | GetUrlRequest | PromptForUserInputRequest | CallCustomAgentByPathRequest + | CallBuiltInAgentRequest | UserApprovalRequest ) diff --git a/packages/narada-pyodide/pyproject.toml b/packages/narada-pyodide/pyproject.toml index 10bfc3b..0331a10 100644 --- a/packages/narada-pyodide/pyproject.toml +++ b/packages/narada-pyodide/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "narada-pyodide" -version = "0.0.55" +version = "0.0.56" description = "Pyodide-compatible Python client SDK for Narada" license = "Apache-2.0" readme = "README.md" diff --git a/packages/narada-pyodide/src/narada/window.py b/packages/narada-pyodide/src/narada/window.py index e54b5a2..b986b46 100644 --- a/packages/narada-pyodide/src/narada/window.py +++ b/packages/narada-pyodide/src/narada/window.py @@ -30,6 +30,8 @@ AgenticSelectors, AgentResponse, AgentUsage, + CallBuiltInAgentRequest, + CallBuiltInAgentResponse, CallCustomAgentByPathRequest, CallCustomAgentByPathResponse, CloseWindowRequest, @@ -55,6 +57,7 @@ ReadGoogleSheetResponse, RecordedClick, StructuredOutput, + TextOutput, UserApprovalRequest, UserApprovalResponse, WaitForElementRequest, @@ -659,6 +662,112 @@ async def _run_custom_agent_in_process( critic_result=None, ) + async def _run_built_in_agent_in_process( + self, + *, + agent: Agent, + prompt: str, + clear_chat: bool | None, + output_schema: type[BaseModel] | None, + timeout: int, + ) -> AgentResponse: + """Run a built-in agent (Operator / Core / Productivity / ...) as an + in-process child of the current runnable. + + SDK equivalent of the dashboard's ``call_agent_tool`` extension action: + instead of POSTing to ``/remote-dispatch`` (which mints a new + ``remote_dispatch_request`` row and a fresh ``request_id``), we dispatch + a ``call_built_in_agent`` extension-action. The frontend handler runs + ``MainAgentRunnable`` under the parent's ``runnableOptions``, so the + child inherits the parent's ``requestId`` and surfaces its action chain + inline in the workflow-run detail page. + + Only valid when the SDK is itself executing inside a parent runnable + (i.e. ``_current_request_id()`` is non-None); the caller is responsible + for that check. + """ + trace_start_ms = _trace.now_ms() + agent_type_str = _trace_agent_type(agent) + request_id = self._current_request_id() + # The caller in ``agent()`` only routes here when the parent runnable + # has injected a request_id; the assertion documents that invariant + # for readers and type-checkers. + assert request_id is not None + + try: + result = await self._run_extension_action( + CallBuiltInAgentRequest( + agent_type=agent_type_str, + prompt=prompt, + clear_chat=clear_chat, + response_format=( + { + "type": "jsonSchema", + "jsonSchema": output_schema.model_json_schema(), + } + if output_schema is not None + else None + ), + ), + CallBuiltInAgentResponse, + # The extension-action handler has its own internal timeout + # (the MainAgentRunnable run loop). Cap ours at 300s so we + # don't sit longer than the handler can produce a response. + timeout=min(timeout, 300), + ) + except Exception as err: + _trace.emit_sub_agent_call( + ts_start=trace_start_ms, + agent_type=agent_type_str, + prompt=prompt, + status="error", + error_message=str(err), + request_id=request_id, + ) + raise + + action_trace = ( + parse_action_trace(result.action_trace) + if result.action_trace is not None + else None + ) + output = result.output + structured_output = None + if ( + output_schema is not None + and output is not None + and output.get("type") == "structured" + ): + structured_output = output_schema.model_validate(output["content"]) + + _trace.emit_sub_agent_call( + ts_start=trace_start_ms, + agent_type=agent_type_str, + prompt=prompt, + status="success", + request_id=request_id, + text=result.text, + action_trace_raw=result.action_trace, + ) + + # Usage is rolled up under the parent's request_id on the backend; + # surface zeros locally rather than double-counting in the parent's + # Python trace (mirrors `_run_custom_agent_in_process`). + return AgentResponse( + request_id=request_id, + status="success", + text=result.text, + structured_output=structured_output, + output=( + StructuredOutput(type="structured", content=output["content"]) + if output is not None and output.get("type") == "structured" + else TextOutput(type="text", content=result.text) + ), + usage=AgentUsage(actions=0, credits=0.0), + action_trace=action_trace, + critic_result=None, + ) + # `reasoning` is only valid with the Core Agent. See `dispatch_request` # above for the rationale; the same overload pattern is mirrored here. @overload @@ -778,6 +887,40 @@ async def agent( timeout=timeout, ) + # Calling a built-in agent (Operator / Core / Productivity / ...) + # from inside a parent runnable: route through the in-frontend + # `call_built_in_agent` handler instead of `/remote-dispatch`, so the + # sub-agent shares the parent's `request_id` and its action chain + # renders inline in the workflow-run detail page (the same way an + # Operator step inside a GUI workflow already does). + # + # v1 supports plain prompts plus `clear_chat` and `output_schema`, + # which the frontend handler maps to the same responseFormat path as + # `/remote-dispatch`. Advanced kwargs that the dashboard in-process + # path can't express today (reasoning, critic, generate_gif, + # mcp_servers, etc.) + # fall through to `dispatch_request`. That keeps backwards + # compatibility for callers that depend on those features at the cost + # of a new request_id in those specific cases. + if ( + isinstance(agent, Agent) + and self._current_parent_run_ids() + and self._current_request_id() is not None + and reasoning is None + and critic is None + and generate_gif is None + and not mcp_servers + and not secret_variables + and not input_variables + ): + return await self._run_built_in_agent_in_process( + agent=agent, + prompt=prompt, + clear_chat=clear_chat, + output_schema=output_schema, + timeout=timeout, + ) + # Branch on `reasoning` so each call site binds a single, typed overload # of `dispatch_request`. The validation also lives in `dispatch_request` # itself (defense in depth + reachable when callers go straight to the diff --git a/packages/narada-pyodide/tests/test_cloud_browser.py b/packages/narada-pyodide/tests/test_cloud_browser.py index 3a058a1..5b7480d 100644 --- a/packages/narada-pyodide/tests/test_cloud_browser.py +++ b/packages/narada-pyodide/tests/test_cloud_browser.py @@ -8,6 +8,7 @@ import pytest from packaging.version import InvalidVersion +from pydantic import BaseModel PROJECT_ROOT = Path(__file__).resolve().parents[3] PYODIDE_SRC = PROJECT_ROOT / "packages" / "narada-pyodide" / "src" @@ -43,6 +44,15 @@ def to_py(self) -> object: return self._value +class _PaperInfo(BaseModel): + title: str + url: str + + +class _Papers(BaseModel): + papers: list[_PaperInfo] + + def _clear_modules() -> None: for name in list(sys.modules): if name == "narada" or name.startswith("narada."): @@ -591,3 +601,105 @@ async def test_local_browser_window_dispatch_request_uses_latest_parent_run_ids( second_post = json.loads(pyfetch.await_args_list[2].kwargs["body"]) assert first_post["parentRunIds"] == ["run-a"] assert second_post["parentRunIds"] == ["run-b", "run-c"] + + +@pytest.mark.asyncio +async def test_local_browser_window_builtin_agents_with_clear_chat_share_parent_request( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("NARADA_API_KEY", "test-api-key") + monkeypatch.setenv("NARADA_BROWSER_WINDOW_ID", "browser-window-123") + pyfetch = AsyncMock( + side_effect=[ + _FakeResponse( + json_data={ + "status": "success", + "data": json.dumps( + { + "text": json.dumps( + { + "papers": [ + { + "title": "Paper 1", + "url": "https://arxiv.org/abs/1", + } + ] + } + ), + "output": { + "type": "structured", + "content": { + "papers": [ + { + "title": "Paper 1", + "url": "https://arxiv.org/abs/1", + } + ] + }, + }, + } + ), + } + ), + _FakeResponse( + json_data={ + "status": "success", + "data": json.dumps({"text": "operator done"}), + } + ), + ] + ) + narada_pkg, _, window_module = _import_pyodide_narada( + monkeypatch, pyfetch=pyfetch + ) + window_module._narada_parent_run_ids = _FakeJsProxy(["parent-run"]) + window_module._narada_request_id = "request-123" + + window = window_module.LocalBrowserWindow() + + productivity_response = await window.agent( + prompt="summarize my day", + agent=narada_pkg.Agent.PRODUCTIVITY, + output_schema=_Papers, + clear_chat=True, + ) + operator_response = await window.agent( + prompt="click the button", + agent=narada_pkg.Agent.OPERATOR, + clear_chat=False, + ) + + assert productivity_response.request_id == "request-123" + assert productivity_response.structured_output == _Papers( + papers=[_PaperInfo(title="Paper 1", url="https://arxiv.org/abs/1")] + ) + assert operator_response.request_id == "request-123" + assert operator_response.text == "operator done" + assert pyfetch.await_count == 2 + + productivity_payload = json.loads(pyfetch.await_args_list[0].kwargs["body"]) + operator_payload = json.loads(pyfetch.await_args_list[1].kwargs["body"]) + assert pyfetch.await_args_list[0].args[0].endswith("/extension-actions") + assert pyfetch.await_args_list[1].args[0].endswith("/extension-actions") + assert productivity_payload["requestId"] == "request-123" + assert productivity_payload["parentRunIds"] == ["parent-run"] + assert productivity_payload["action"]["name"] == "call_built_in_agent" + assert productivity_payload["action"]["agent_type"] == "generalist" + assert productivity_payload["action"]["prompt"] == "summarize my day" + assert productivity_payload["action"]["clear_chat"] is True + assert productivity_payload["action"]["response_format"]["type"] == "jsonSchema" + assert ( + productivity_payload["action"]["response_format"]["jsonSchema"]["properties"][ + "papers" + ]["type"] + == "array" + ) + assert operator_payload["requestId"] == "request-123" + assert operator_payload["parentRunIds"] == ["parent-run"] + assert operator_payload["action"] == { + "name": "call_built_in_agent", + "agent_type": "operator", + "prompt": "click the button", + "clear_chat": False, + "response_format": None, + } diff --git a/uv.lock b/uv.lock index c3d9623..73e9752 100644 --- a/uv.lock +++ b/uv.lock @@ -356,7 +356,7 @@ requires-dist = [{ name = "pydantic", specifier = "==2.12.5" }] [[package]] name = "narada-pyodide" -version = "0.0.55" +version = "0.0.56" source = { editable = "packages/narada-pyodide" } dependencies = [ { name = "narada-core" }, From 413fc2c80db07227123782e364569d0e42c852cc Mon Sep 17 00:00:00 2001 From: sebzhao Date: Thu, 21 May 2026 11:36:22 -0700 Subject: [PATCH 06/13] refactor: remove any extension hijacking logic --- .../src/narada_core/actions/models.py | 46 --- packages/narada-pyodide/src/narada/window.py | 294 ------------------ .../tests/test_cloud_browser.py | 112 ------- 3 files changed, 452 deletions(-) diff --git a/packages/narada-core/src/narada_core/actions/models.py b/packages/narada-core/src/narada_core/actions/models.py index 3a6c850..b75a5e7 100644 --- a/packages/narada-core/src/narada_core/actions/models.py +++ b/packages/narada-core/src/narada_core/actions/models.py @@ -15,7 +15,6 @@ from pydantic import ( BaseModel, Field, - RootModel, ) from narada_core.tracing import model as tracing_model @@ -409,49 +408,6 @@ class PromptForUserInputResponse(BaseModel): values_by_name: dict[str, Any] -class CallCustomAgentByPathRequest(BaseModel): - name: Literal["call_custom_agent_by_path"] = "call_custom_agent_by_path" - agent_path: str - prompt: str - input_variables: dict[str, Any] = Field(default_factory=dict) - - -class CallCustomAgentByPathResponse(RootModel[dict[str, Any]]): - pass - - -class CallBuiltInAgentRequest(BaseModel): - """SDK-direct equivalent of ``call_agent_tool`` for built-in agent types - (Operator, Core, Productivity, ...). Dispatched from - ``window.agent(agent=Agent.X)`` when running inside a parent runnable so the - sub-agent inherits the parent's ``requestId`` instead of minting a new - ``remote_dispatch_request`` via ``dispatch_request``. - - ``agent_type`` is the dashboard's ``ApaAgentType`` string slug (e.g. - ``"operator"``, ``"coreAgent"``, ``"generalist"``). - """ - - name: Literal["call_built_in_agent"] = "call_built_in_agent" - agent_type: str - prompt: str - clear_chat: bool | None = None - response_format: dict[str, Any] | None = None - - -class CallBuiltInAgentResponse(BaseModel): - """JSON body returned in ``ExtensionActionResponse.data`` for - ``call_built_in_agent``. ``action_trace`` carries the same shape as the - legacy ``actionTrace`` field on ``dispatch_request`` responses, so the SDK - can reuse ``parse_action_trace`` to materialise it. - """ - - text: str - output: dict[str, Any] | None = None - action_trace: list[dict[str, Any]] | None = Field(default=None, alias="actionTrace") - - model_config = {"populate_by_name": True} - - class UserApprovalRequest(BaseModel): name: Literal["user_approval"] = "user_approval" step_id: str @@ -480,8 +436,6 @@ class UserApprovalResponse(BaseModel): | GetScreenshotRequest | GetUrlRequest | PromptForUserInputRequest - | CallCustomAgentByPathRequest - | CallBuiltInAgentRequest | UserApprovalRequest ) diff --git a/packages/narada-pyodide/src/narada/window.py b/packages/narada-pyodide/src/narada/window.py index b986b46..5fac8e1 100644 --- a/packages/narada-pyodide/src/narada/window.py +++ b/packages/narada-pyodide/src/narada/window.py @@ -30,10 +30,6 @@ AgenticSelectors, AgentResponse, AgentUsage, - CallBuiltInAgentRequest, - CallBuiltInAgentResponse, - CallCustomAgentByPathRequest, - CallCustomAgentByPathResponse, CloseWindowRequest, CriticResult, ExtensionActionRequest, @@ -56,8 +52,6 @@ ReadGoogleSheetRequest, ReadGoogleSheetResponse, RecordedClick, - StructuredOutput, - TextOutput, UserApprovalRequest, UserApprovalResponse, WaitForElementRequest, @@ -106,20 +100,6 @@ def _parent_run_ids() -> list[str]: ) -def _request_id() -> str | None: - # `_narada_request_id` is a plain string injected by the JavaScript harness that identifies - # the currently-running parent runnable's request. The SDK forwards it on extension-action - # requests so the spawned child runnable inherits the same request ID instead of the - # frontend handler minting a new one and orphaning the child run. - try: - value = _narada_request_id # noqa: F821 # pyright: ignore[reportUndefinedVariable] - except NameError: - return None - if value is None: - return None - return str(value) - - if TYPE_CHECKING: # Magic function injected by the JavaScript harness to get the current user's ID token. async def _narada_get_id_token() -> str: ... @@ -232,16 +212,6 @@ def _current_parent_run_ids(self) -> list[str] | None: """ return None - def _current_request_id(self) -> str | None: - """Returns the parent runnable's request ID to forward on extension-action requests. - - Mirrors `_current_parent_run_ids` — only requests targeting the current browser window - should inherit it. Without this, extension-action dispatches that spawn a child runnable - (e.g. `call_run_custom_agent_tool`) end up with a fresh request ID and the child run - is not linked to the parent in observability. - """ - return None - async def _get_auth_headers(self) -> dict[str, str]: return await _build_auth_headers( api_key=self._api_key, @@ -577,197 +547,6 @@ async def dispatch_request( ) raise - async def _run_custom_agent_in_process( - self, - *, - agent_path: str, - prompt: str, - output_schema: type[BaseModel] | None, - input_variables: dict[str, Any] | None, - timeout: int, - ) -> AgentResponse: - """Run a custom agent identified by Agent Studio path as an in-process - child of the current runnable. - - This is the SDK equivalent of the GUI's `call_run_custom_agent_tool`: - instead of POSTing to `/remote-dispatch` (which mints a new - `remote_dispatch_request` row and a fresh request_id), we dispatch a - `call_custom_agent_by_path` extension-action so the frontend handler - runs `runCustomAgentWorkflow` under the parent's `runnableOptions` — - the child inherits the parent's `requestId` and the run shows up as - one logical execution in observability. - - Only valid when the SDK is itself executing inside a parent runnable - (i.e. `_current_request_id()` is non-None); callers route here from - `agent()`. - """ - trace_start_ms = _trace.now_ms() - agent_type_str = _trace_agent_type(agent_path) - request_id = self._current_request_id() - # `agent()` is the only caller and only routes here when the parent - # runnable has injected a request_id; the assertion documents that - # invariant for readers and type-checkers. - assert request_id is not None - - try: - result = await self._run_extension_action( - CallCustomAgentByPathRequest( - agent_path=agent_path, - prompt=prompt, - input_variables=input_variables or {}, - ), - CallCustomAgentByPathResponse, - # The extension-action handler in the frontend has its own - # internal timeout; cap ours at 300s so we don't sit longer - # than the handler can produce a response. - timeout=min(timeout, 300), - ) - except Exception as err: - _trace.emit_sub_agent_call( - ts_start=trace_start_ms, - agent_type=agent_type_str, - prompt=prompt, - status="error", - error_message=str(err), - request_id=request_id, - ) - raise - - output_content = result.root - structured_output = ( - output_schema.model_validate(output_content) - if output_schema is not None - else None - ) - - _trace.emit_sub_agent_call( - ts_start=trace_start_ms, - agent_type=agent_type_str, - prompt=prompt, - status="success", - request_id=request_id, - ) - - # Usage for a sub-workflow run is rolled up under the parent's - # request_id on the backend; surface zeros locally rather than - # double-counting in the parent's Python trace. - return AgentResponse( - request_id=request_id, - status="success", - text=json.dumps(output_content), - structured_output=structured_output, - output=StructuredOutput(type="structured", content=output_content), - usage=AgentUsage(actions=0, credits=0.0), - action_trace=None, - critic_result=None, - ) - - async def _run_built_in_agent_in_process( - self, - *, - agent: Agent, - prompt: str, - clear_chat: bool | None, - output_schema: type[BaseModel] | None, - timeout: int, - ) -> AgentResponse: - """Run a built-in agent (Operator / Core / Productivity / ...) as an - in-process child of the current runnable. - - SDK equivalent of the dashboard's ``call_agent_tool`` extension action: - instead of POSTing to ``/remote-dispatch`` (which mints a new - ``remote_dispatch_request`` row and a fresh ``request_id``), we dispatch - a ``call_built_in_agent`` extension-action. The frontend handler runs - ``MainAgentRunnable`` under the parent's ``runnableOptions``, so the - child inherits the parent's ``requestId`` and surfaces its action chain - inline in the workflow-run detail page. - - Only valid when the SDK is itself executing inside a parent runnable - (i.e. ``_current_request_id()`` is non-None); the caller is responsible - for that check. - """ - trace_start_ms = _trace.now_ms() - agent_type_str = _trace_agent_type(agent) - request_id = self._current_request_id() - # The caller in ``agent()`` only routes here when the parent runnable - # has injected a request_id; the assertion documents that invariant - # for readers and type-checkers. - assert request_id is not None - - try: - result = await self._run_extension_action( - CallBuiltInAgentRequest( - agent_type=agent_type_str, - prompt=prompt, - clear_chat=clear_chat, - response_format=( - { - "type": "jsonSchema", - "jsonSchema": output_schema.model_json_schema(), - } - if output_schema is not None - else None - ), - ), - CallBuiltInAgentResponse, - # The extension-action handler has its own internal timeout - # (the MainAgentRunnable run loop). Cap ours at 300s so we - # don't sit longer than the handler can produce a response. - timeout=min(timeout, 300), - ) - except Exception as err: - _trace.emit_sub_agent_call( - ts_start=trace_start_ms, - agent_type=agent_type_str, - prompt=prompt, - status="error", - error_message=str(err), - request_id=request_id, - ) - raise - - action_trace = ( - parse_action_trace(result.action_trace) - if result.action_trace is not None - else None - ) - output = result.output - structured_output = None - if ( - output_schema is not None - and output is not None - and output.get("type") == "structured" - ): - structured_output = output_schema.model_validate(output["content"]) - - _trace.emit_sub_agent_call( - ts_start=trace_start_ms, - agent_type=agent_type_str, - prompt=prompt, - status="success", - request_id=request_id, - text=result.text, - action_trace_raw=result.action_trace, - ) - - # Usage is rolled up under the parent's request_id on the backend; - # surface zeros locally rather than double-counting in the parent's - # Python trace (mirrors `_run_custom_agent_in_process`). - return AgentResponse( - request_id=request_id, - status="success", - text=result.text, - structured_output=structured_output, - output=( - StructuredOutput(type="structured", content=output["content"]) - if output is not None and output.get("type") == "structured" - else TextOutput(type="text", content=result.text) - ), - usage=AgentUsage(actions=0, credits=0.0), - action_trace=action_trace, - critic_result=None, - ) - # `reasoning` is only valid with the Core Agent. See `dispatch_request` # above for the rationale; the same overload pattern is mirrored here. @overload @@ -855,72 +634,6 @@ async def agent( timeout: int = 1000, ) -> AgentResponse: """Invokes an agent in the Narada extension side panel chat.""" - # Calling a custom agent by Agent Studio path from inside a parent - # runnable: route through the in-frontend `call_custom_agent_by_path` - # handler instead of `/remote-dispatch`, so the child workflow shares - # the parent's request_id and shows up as one logical run in - # observability. Mirrors the GUI's `call_run_custom_agent_tool` flow. - # `reasoning` and `critic` aren't meaningful for a sub-workflow (the - # child workflow defines its own steps); reject so callers don't think - # they're being applied. - if ( - isinstance(agent, str) - and agent.startswith("/") - and self._current_parent_run_ids() - and self._current_request_id() is not None - ): - if reasoning is not None: - raise ValueError( - "`reasoning` is not supported when calling a custom agent " - "by path from inside a parent workflow" - ) - if critic is not None: - raise ValueError( - "`critic` is not supported when calling a custom agent by " - "path from inside a parent workflow" - ) - return await self._run_custom_agent_in_process( - agent_path=agent, - prompt=prompt, - output_schema=output_schema, - input_variables=input_variables, - timeout=timeout, - ) - - # Calling a built-in agent (Operator / Core / Productivity / ...) - # from inside a parent runnable: route through the in-frontend - # `call_built_in_agent` handler instead of `/remote-dispatch`, so the - # sub-agent shares the parent's `request_id` and its action chain - # renders inline in the workflow-run detail page (the same way an - # Operator step inside a GUI workflow already does). - # - # v1 supports plain prompts plus `clear_chat` and `output_schema`, - # which the frontend handler maps to the same responseFormat path as - # `/remote-dispatch`. Advanced kwargs that the dashboard in-process - # path can't express today (reasoning, critic, generate_gif, - # mcp_servers, etc.) - # fall through to `dispatch_request`. That keeps backwards - # compatibility for callers that depend on those features at the cost - # of a new request_id in those specific cases. - if ( - isinstance(agent, Agent) - and self._current_parent_run_ids() - and self._current_request_id() is not None - and reasoning is None - and critic is None - and generate_gif is None - and not mcp_servers - and not secret_variables - and not input_variables - ): - return await self._run_built_in_agent_in_process( - agent=agent, - prompt=prompt, - clear_chat=clear_chat, - output_schema=output_schema, - timeout=timeout, - ) - # Branch on `reasoning` so each call site binds a single, typed overload # of `dispatch_request`. The validation also lives in `dispatch_request` # itself (defense in depth + reachable when callers go straight to the @@ -1278,9 +991,6 @@ async def _run_extension_action( parent_run_ids = self._current_parent_run_ids() if parent_run_ids: body["parentRunIds"] = parent_run_ids - request_id = self._current_request_id() - if request_id is not None: - body["requestId"] = request_id if timeout is not None: body["timeout"] = timeout @@ -1362,10 +1072,6 @@ def __str__(self) -> str: def _current_parent_run_ids(self) -> list[str] | None: return _parent_run_ids() - @override - def _current_request_id(self) -> str | None: - return _request_id() - class RemoteBrowserWindow(BaseBrowserWindow): def __init__( diff --git a/packages/narada-pyodide/tests/test_cloud_browser.py b/packages/narada-pyodide/tests/test_cloud_browser.py index 5b7480d..3a058a1 100644 --- a/packages/narada-pyodide/tests/test_cloud_browser.py +++ b/packages/narada-pyodide/tests/test_cloud_browser.py @@ -8,7 +8,6 @@ import pytest from packaging.version import InvalidVersion -from pydantic import BaseModel PROJECT_ROOT = Path(__file__).resolve().parents[3] PYODIDE_SRC = PROJECT_ROOT / "packages" / "narada-pyodide" / "src" @@ -44,15 +43,6 @@ def to_py(self) -> object: return self._value -class _PaperInfo(BaseModel): - title: str - url: str - - -class _Papers(BaseModel): - papers: list[_PaperInfo] - - def _clear_modules() -> None: for name in list(sys.modules): if name == "narada" or name.startswith("narada."): @@ -601,105 +591,3 @@ async def test_local_browser_window_dispatch_request_uses_latest_parent_run_ids( second_post = json.loads(pyfetch.await_args_list[2].kwargs["body"]) assert first_post["parentRunIds"] == ["run-a"] assert second_post["parentRunIds"] == ["run-b", "run-c"] - - -@pytest.mark.asyncio -async def test_local_browser_window_builtin_agents_with_clear_chat_share_parent_request( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setenv("NARADA_API_KEY", "test-api-key") - monkeypatch.setenv("NARADA_BROWSER_WINDOW_ID", "browser-window-123") - pyfetch = AsyncMock( - side_effect=[ - _FakeResponse( - json_data={ - "status": "success", - "data": json.dumps( - { - "text": json.dumps( - { - "papers": [ - { - "title": "Paper 1", - "url": "https://arxiv.org/abs/1", - } - ] - } - ), - "output": { - "type": "structured", - "content": { - "papers": [ - { - "title": "Paper 1", - "url": "https://arxiv.org/abs/1", - } - ] - }, - }, - } - ), - } - ), - _FakeResponse( - json_data={ - "status": "success", - "data": json.dumps({"text": "operator done"}), - } - ), - ] - ) - narada_pkg, _, window_module = _import_pyodide_narada( - monkeypatch, pyfetch=pyfetch - ) - window_module._narada_parent_run_ids = _FakeJsProxy(["parent-run"]) - window_module._narada_request_id = "request-123" - - window = window_module.LocalBrowserWindow() - - productivity_response = await window.agent( - prompt="summarize my day", - agent=narada_pkg.Agent.PRODUCTIVITY, - output_schema=_Papers, - clear_chat=True, - ) - operator_response = await window.agent( - prompt="click the button", - agent=narada_pkg.Agent.OPERATOR, - clear_chat=False, - ) - - assert productivity_response.request_id == "request-123" - assert productivity_response.structured_output == _Papers( - papers=[_PaperInfo(title="Paper 1", url="https://arxiv.org/abs/1")] - ) - assert operator_response.request_id == "request-123" - assert operator_response.text == "operator done" - assert pyfetch.await_count == 2 - - productivity_payload = json.loads(pyfetch.await_args_list[0].kwargs["body"]) - operator_payload = json.loads(pyfetch.await_args_list[1].kwargs["body"]) - assert pyfetch.await_args_list[0].args[0].endswith("/extension-actions") - assert pyfetch.await_args_list[1].args[0].endswith("/extension-actions") - assert productivity_payload["requestId"] == "request-123" - assert productivity_payload["parentRunIds"] == ["parent-run"] - assert productivity_payload["action"]["name"] == "call_built_in_agent" - assert productivity_payload["action"]["agent_type"] == "generalist" - assert productivity_payload["action"]["prompt"] == "summarize my day" - assert productivity_payload["action"]["clear_chat"] is True - assert productivity_payload["action"]["response_format"]["type"] == "jsonSchema" - assert ( - productivity_payload["action"]["response_format"]["jsonSchema"]["properties"][ - "papers" - ]["type"] - == "array" - ) - assert operator_payload["requestId"] == "request-123" - assert operator_payload["parentRunIds"] == ["parent-run"] - assert operator_payload["action"] == { - "name": "call_built_in_agent", - "agent_type": "operator", - "prompt": "click the button", - "clear_chat": False, - "response_format": None, - } From 7ca491b08d283f95a372c70d0ead2c917c459fd6 Mon Sep 17 00:00:00 2001 From: sebzhao Date: Thu, 21 May 2026 13:07:51 -0700 Subject: [PATCH 07/13] feat: inject parent request id --- packages/narada-pyodide/src/narada/window.py | 20 +++++++++++++++++-- .../tests/test_cloud_browser.py | 6 ++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/narada-pyodide/src/narada/window.py b/packages/narada-pyodide/src/narada/window.py index 5fac8e1..2fb54dd 100644 --- a/packages/narada-pyodide/src/narada/window.py +++ b/packages/narada-pyodide/src/narada/window.py @@ -83,8 +83,7 @@ from . import _trace from .retry import pyfetch_with_retries -# Magic variable injected by the JavaScript harness that stores the IDs of the current runnables -# in the stack on the frontend. +# Magic variables injected by the JavaScript harness for the current frontend runnable. logger = logging.getLogger(__name__) @@ -100,6 +99,16 @@ def _parent_run_ids() -> list[str]: ) +def _parent_request_id() -> str | None: + try: + return cast( + str | None, + _narada_request_id, # noqa: F821 # pyright: ignore[reportUndefinedVariable] + ) + except NameError: + return None + + if TYPE_CHECKING: # Magic function injected by the JavaScript harness to get the current user's ID token. async def _narada_get_id_token() -> str: ... @@ -212,6 +221,10 @@ def _current_parent_run_ids(self) -> list[str] | None: """ return None + def _current_parent_request_id(self) -> str | None: + """Returns the remote dispatch request ID to store as a child request's parent.""" + return _parent_request_id() + async def _get_auth_headers(self) -> dict[str, str]: return await _build_auth_headers( api_key=self._api_key, @@ -384,6 +397,9 @@ async def dispatch_request( parent_run_ids = self._current_parent_run_ids() if parent_run_ids: body["parentRunIds"] = parent_run_ids + parent_request_id = self._current_parent_request_id() + if parent_request_id: + body["parentRequestId"] = parent_request_id cloud_browser_session_id = self.cloud_browser_session_id if cloud_browser_session_id is not None: body["cloudBrowserSessionId"] = cloud_browser_session_id diff --git a/packages/narada-pyodide/tests/test_cloud_browser.py b/packages/narada-pyodide/tests/test_cloud_browser.py index 3a058a1..5704416 100644 --- a/packages/narada-pyodide/tests/test_cloud_browser.py +++ b/packages/narada-pyodide/tests/test_cloud_browser.py @@ -258,6 +258,7 @@ async def test_cloud_browser_window_dispatch_request_omits_parent_run_ids( ) _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) window_module._narada_parent_run_ids = _FakeJsProxy(["outer-run", "inner-run"]) + window_module._narada_request_id = "parent-request-123" window = window_module.CloudBrowserWindow( browser_window_id="browser-window-123", @@ -275,6 +276,7 @@ async def test_cloud_browser_window_dispatch_request_omits_parent_run_ids( assert payload["cloudBrowserSessionId"] == "session-123" assert payload["prompt"] == "/Operator hello from cloud browser" assert "parentRunIds" not in payload + assert payload["parentRequestId"] == "parent-request-123" @pytest.mark.asyncio @@ -579,9 +581,11 @@ async def test_local_browser_window_dispatch_request_uses_latest_parent_run_ids( window = window_module.LocalBrowserWindow() window_module._narada_parent_run_ids = _FakeJsProxy(["run-a"]) + window_module._narada_request_id = "parent-request-a" first_response = await window.dispatch_request(prompt="first prompt") window_module._narada_parent_run_ids = _FakeJsProxy(["run-b", "run-c"]) + window_module._narada_request_id = "parent-request-b" second_response = await window.dispatch_request(prompt="second prompt") assert first_response["status"] == "success" @@ -591,3 +595,5 @@ async def test_local_browser_window_dispatch_request_uses_latest_parent_run_ids( second_post = json.loads(pyfetch.await_args_list[2].kwargs["body"]) assert first_post["parentRunIds"] == ["run-a"] assert second_post["parentRunIds"] == ["run-b", "run-c"] + assert first_post["parentRequestId"] == "parent-request-a" + assert second_post["parentRequestId"] == "parent-request-b" From ab4b0059ac0d0ad77f77df7abd074d37198b2693 Mon Sep 17 00:00:00 2001 From: sebzhao Date: Thu, 21 May 2026 16:17:25 -0700 Subject: [PATCH 08/13] Revert "feat: inject parent request id" This reverts commit 7ca491b08d283f95a372c70d0ead2c917c459fd6. --- packages/narada-pyodide/src/narada/window.py | 20 ++----------------- .../tests/test_cloud_browser.py | 6 ------ 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/packages/narada-pyodide/src/narada/window.py b/packages/narada-pyodide/src/narada/window.py index 557f8a0..c83d985 100644 --- a/packages/narada-pyodide/src/narada/window.py +++ b/packages/narada-pyodide/src/narada/window.py @@ -84,7 +84,8 @@ from . import _trace from .retry import pyfetch_with_retries -# Magic variables injected by the JavaScript harness for the current frontend runnable. +# Magic variable injected by the JavaScript harness that stores the IDs of the current runnables +# in the stack on the frontend. logger = logging.getLogger(__name__) @@ -100,16 +101,6 @@ def _parent_run_ids() -> list[str]: ) -def _parent_request_id() -> str | None: - try: - return cast( - str | None, - _narada_request_id, # noqa: F821 # pyright: ignore[reportUndefinedVariable] - ) - except NameError: - return None - - if TYPE_CHECKING: # Magic function injected by the JavaScript harness to get the current user's ID token. async def _narada_get_id_token() -> str: ... @@ -222,10 +213,6 @@ def _current_parent_run_ids(self) -> list[str] | None: """ return None - def _current_parent_request_id(self) -> str | None: - """Returns the remote dispatch request ID to store as a child request's parent.""" - return _parent_request_id() - async def _get_auth_headers(self) -> dict[str, str]: return await _build_auth_headers( api_key=self._api_key, @@ -398,9 +385,6 @@ async def dispatch_request( parent_run_ids = self._current_parent_run_ids() if parent_run_ids: body["parentRunIds"] = parent_run_ids - parent_request_id = self._current_parent_request_id() - if parent_request_id: - body["parentRequestId"] = parent_request_id cloud_browser_session_id = self.cloud_browser_session_id if cloud_browser_session_id is not None: body["cloudBrowserSessionId"] = cloud_browser_session_id diff --git a/packages/narada-pyodide/tests/test_cloud_browser.py b/packages/narada-pyodide/tests/test_cloud_browser.py index 6ef8cbe..75561d3 100644 --- a/packages/narada-pyodide/tests/test_cloud_browser.py +++ b/packages/narada-pyodide/tests/test_cloud_browser.py @@ -258,7 +258,6 @@ async def test_cloud_browser_window_dispatch_request_omits_parent_run_ids( ) _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) window_module._narada_parent_run_ids = _FakeJsProxy(["outer-run", "inner-run"]) - window_module._narada_request_id = "parent-request-123" window = window_module.CloudBrowserWindow( browser_window_id="browser-window-123", @@ -276,7 +275,6 @@ async def test_cloud_browser_window_dispatch_request_omits_parent_run_ids( assert payload["cloudBrowserSessionId"] == "session-123" assert payload["prompt"] == "/Operator hello from cloud browser" assert "parentRunIds" not in payload - assert payload["parentRequestId"] == "parent-request-123" @pytest.mark.asyncio @@ -645,11 +643,9 @@ async def test_local_browser_window_dispatch_request_uses_latest_parent_run_ids( window = window_module.LocalBrowserWindow() window_module._narada_parent_run_ids = _FakeJsProxy(["run-a"]) - window_module._narada_request_id = "parent-request-a" first_response = await window.dispatch_request(prompt="first prompt") window_module._narada_parent_run_ids = _FakeJsProxy(["run-b", "run-c"]) - window_module._narada_request_id = "parent-request-b" second_response = await window.dispatch_request(prompt="second prompt") assert first_response["status"] == "success" @@ -659,5 +655,3 @@ async def test_local_browser_window_dispatch_request_uses_latest_parent_run_ids( second_post = json.loads(pyfetch.await_args_list[2].kwargs["body"]) assert first_post["parentRunIds"] == ["run-a"] assert second_post["parentRunIds"] == ["run-b", "run-c"] - assert first_post["parentRequestId"] == "parent-request-a" - assert second_post["parentRequestId"] == "parent-request-b" From 9a3e69708b45293f6b19293513744a4cb8fe3f8e Mon Sep 17 00:00:00 2001 From: sebzhao Date: Fri, 22 May 2026 14:57:48 -0700 Subject: [PATCH 09/13] refactor: funnel parent request id --- packages/narada-pyodide/src/narada/window.py | 21 +++ .../tests/test_cloud_browser.py | 137 ++++++++++++++++++ 2 files changed, 158 insertions(+) diff --git a/packages/narada-pyodide/src/narada/window.py b/packages/narada-pyodide/src/narada/window.py index c83d985..4032889 100644 --- a/packages/narada-pyodide/src/narada/window.py +++ b/packages/narada-pyodide/src/narada/window.py @@ -1,4 +1,5 @@ import asyncio +import builtins import json import logging import os @@ -101,10 +102,20 @@ def _parent_run_ids() -> list[str]: ) +def _parent_request_id() -> str | None: + parent_request_id = getattr(builtins, "_narada_request_id", None) + if isinstance(parent_request_id, str): + return parent_request_id + parent_request_id = globals().get("_narada_request_id") + return parent_request_id if isinstance(parent_request_id, str) else None + + if TYPE_CHECKING: # Magic function injected by the JavaScript harness to get the current user's ID token. async def _narada_get_id_token() -> str: ... + _narada_request_id: str | None + _StructuredOutput = TypeVar("_StructuredOutput", bound=BaseModel) @@ -213,6 +224,10 @@ def _current_parent_run_ids(self) -> list[str] | None: """ return None + def _current_parent_request_id(self) -> str | None: + """Returns the remote-dispatch request that owns the current Python execution.""" + return _parent_request_id() + async def _get_auth_headers(self) -> dict[str, str]: return await _build_auth_headers( api_key=self._api_key, @@ -385,6 +400,9 @@ async def dispatch_request( parent_run_ids = self._current_parent_run_ids() if parent_run_ids: body["parentRunIds"] = parent_run_ids + parent_request_id = self._current_parent_request_id() + if parent_request_id is not None: + body["parentRequestId"] = parent_request_id cloud_browser_session_id = self.cloud_browser_session_id if cloud_browser_session_id is not None: body["cloudBrowserSessionId"] = cloud_browser_session_id @@ -992,6 +1010,9 @@ async def _run_extension_action( parent_run_ids = self._current_parent_run_ids() if parent_run_ids: body["parentRunIds"] = parent_run_ids + parent_request_id = self._current_parent_request_id() + if parent_request_id is not None: + body["requestId"] = parent_request_id if timeout is not None: body["timeout"] = timeout diff --git a/packages/narada-pyodide/tests/test_cloud_browser.py b/packages/narada-pyodide/tests/test_cloud_browser.py index 75561d3..02bbba7 100644 --- a/packages/narada-pyodide/tests/test_cloud_browser.py +++ b/packages/narada-pyodide/tests/test_cloud_browser.py @@ -1,5 +1,6 @@ from __future__ import annotations +import builtins import json import sys from pathlib import Path @@ -85,6 +86,8 @@ def new() -> SimpleNamespace: client_module = importlib.import_module("narada.client") window_module = importlib.import_module("narada.window") window_module._narada_parent_run_ids = _FakeJsProxy([]) + window_module._narada_request_id = None + monkeypatch.setattr(builtins, "_narada_request_id", None, raising=False) window_module._narada_get_id_token = AsyncMock(return_value="frontend-id-token") return narada_pkg, client_module, window_module @@ -277,6 +280,70 @@ async def test_cloud_browser_window_dispatch_request_omits_parent_run_ids( assert "parentRunIds" not in payload +@pytest.mark.asyncio +async def test_cloud_browser_window_dispatch_request_keeps_parent_request_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pyfetch = AsyncMock( + side_effect=[ + _FakeResponse(json_data={"requestId": "child-request-123"}), + _FakeResponse(json_data={"status": "success", "response": None}), + ] + ) + _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) + window_module._narada_parent_run_ids = _FakeJsProxy(["outer-run", "inner-run"]) + monkeypatch.setattr(builtins, "_narada_request_id", "parent-request-123", raising=False) + + window = window_module.CloudBrowserWindow( + browser_window_id="browser-window-123", + session_id="session-123", + api_key="test-api-key", + ) + response = await window.dispatch_request(prompt="hello from cloud browser") + + assert response["status"] == "success" + post_call = pyfetch.await_args_list[0] + payload = json.loads(post_call.kwargs["body"]) + assert payload["parentRequestId"] == "parent-request-123" + assert "parentRunIds" not in payload + + +@pytest.mark.asyncio +async def test_window_agent_keeps_parent_request_id_from_injected_builtins( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pyfetch = AsyncMock( + side_effect=[ + _FakeResponse(json_data={"requestId": "child-request-123"}), + _FakeResponse( + json_data={ + "status": "success", + "response": { + "text": "done", + "output": {"type": "text", "content": "done"}, + }, + "usage": {"actions": 0, "credits": 0}, + } + ), + ] + ) + _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) + window_module._narada_parent_run_ids = _FakeJsProxy(["outer-run", "inner-run"]) + monkeypatch.setattr(builtins, "_narada_request_id", "parent-request-123", 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="run gui child", agent="/$USER/gui-child") + + assert response.status == "success" + post_call = pyfetch.await_args_list[0] + payload = json.loads(post_call.kwargs["body"]) + assert payload["parentRequestId"] == "parent-request-123" + + @pytest.mark.asyncio async def test_cloud_browser_window_dispatch_request_retries_poll_fetch_failures( monkeypatch: pytest.MonkeyPatch, @@ -624,6 +691,30 @@ async def test_remote_browser_window_without_cloud_session_keeps_extension_actio assert "parentRunIds" not in payload +@pytest.mark.asyncio +async def test_remote_browser_window_extension_action_keeps_parent_request_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pyfetch = AsyncMock( + return_value=_FakeResponse(json_data={"status": "success", "data": None}) + ) + _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) + window_module._narada_parent_run_ids = _FakeJsProxy(["outer-run", "inner-run"]) + monkeypatch.setattr(builtins, "_narada_request_id", "parent-request-123", raising=False) + + window = window_module.RemoteBrowserWindow( + browser_window_id="browser-window-123", + api_key="test-api-key", + ) + await window.close() + + call = pyfetch.await_args + assert call is not None + payload = json.loads(call.kwargs["body"]) + assert payload["requestId"] == "parent-request-123" + assert "parentRunIds" not in payload + + @pytest.mark.asyncio async def test_local_browser_window_dispatch_request_uses_latest_parent_run_ids( monkeypatch: pytest.MonkeyPatch, @@ -655,3 +746,49 @@ async def test_local_browser_window_dispatch_request_uses_latest_parent_run_ids( second_post = json.loads(pyfetch.await_args_list[2].kwargs["body"]) assert first_post["parentRunIds"] == ["run-a"] assert second_post["parentRunIds"] == ["run-b", "run-c"] + + +@pytest.mark.asyncio +async def test_local_browser_window_dispatch_request_includes_parent_request_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("NARADA_API_KEY", "test-api-key") + monkeypatch.setenv("NARADA_BROWSER_WINDOW_ID", "browser-window-123") + pyfetch = AsyncMock( + side_effect=[ + _FakeResponse(json_data={"requestId": "child-request-123"}), + _FakeResponse(json_data={"status": "success", "response": None}), + ] + ) + _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) + window_module._narada_parent_run_ids = _FakeJsProxy(["run-a"]) + monkeypatch.setattr(builtins, "_narada_request_id", "parent-request-123", raising=False) + + window = window_module.LocalBrowserWindow() + response = await window.dispatch_request(prompt="child prompt") + + assert response["status"] == "success" + post_payload = json.loads(pyfetch.await_args_list[0].kwargs["body"]) + assert post_payload["parentRequestId"] == "parent-request-123" + assert post_payload["parentRunIds"] == ["run-a"] + + +@pytest.mark.asyncio +async def test_local_browser_window_extension_action_includes_parent_request_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("NARADA_API_KEY", "test-api-key") + monkeypatch.setenv("NARADA_BROWSER_WINDOW_ID", "browser-window-123") + pyfetch = AsyncMock( + return_value=_FakeResponse(json_data={"status": "success", "data": None}) + ) + _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) + window_module._narada_parent_run_ids = _FakeJsProxy(["run-a"]) + monkeypatch.setattr(builtins, "_narada_request_id", "parent-request-123", raising=False) + + window = window_module.LocalBrowserWindow() + await window.close() + + post_payload = json.loads(pyfetch.await_args.kwargs["body"]) + assert post_payload["requestId"] == "parent-request-123" + assert post_payload["parentRunIds"] == ["run-a"] From e59d02a9111010b2574e54466b96e3ca255b3e4e Mon Sep 17 00:00:00 2001 From: sebzhao Date: Fri, 22 May 2026 14:57:59 -0700 Subject: [PATCH 10/13] style: lint --- .../tests/test_cloud_browser.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/narada-pyodide/tests/test_cloud_browser.py b/packages/narada-pyodide/tests/test_cloud_browser.py index 02bbba7..da7395e 100644 --- a/packages/narada-pyodide/tests/test_cloud_browser.py +++ b/packages/narada-pyodide/tests/test_cloud_browser.py @@ -292,7 +292,9 @@ async def test_cloud_browser_window_dispatch_request_keeps_parent_request_id( ) _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) window_module._narada_parent_run_ids = _FakeJsProxy(["outer-run", "inner-run"]) - monkeypatch.setattr(builtins, "_narada_request_id", "parent-request-123", raising=False) + monkeypatch.setattr( + builtins, "_narada_request_id", "parent-request-123", raising=False + ) window = window_module.CloudBrowserWindow( browser_window_id="browser-window-123", @@ -329,7 +331,9 @@ async def test_window_agent_keeps_parent_request_id_from_injected_builtins( ) _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) window_module._narada_parent_run_ids = _FakeJsProxy(["outer-run", "inner-run"]) - monkeypatch.setattr(builtins, "_narada_request_id", "parent-request-123", raising=False) + monkeypatch.setattr( + builtins, "_narada_request_id", "parent-request-123", raising=False + ) window = window_module.CloudBrowserWindow( browser_window_id="browser-window-123", @@ -700,7 +704,9 @@ async def test_remote_browser_window_extension_action_keeps_parent_request_id( ) _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) window_module._narada_parent_run_ids = _FakeJsProxy(["outer-run", "inner-run"]) - monkeypatch.setattr(builtins, "_narada_request_id", "parent-request-123", raising=False) + monkeypatch.setattr( + builtins, "_narada_request_id", "parent-request-123", raising=False + ) window = window_module.RemoteBrowserWindow( browser_window_id="browser-window-123", @@ -762,7 +768,9 @@ async def test_local_browser_window_dispatch_request_includes_parent_request_id( ) _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) window_module._narada_parent_run_ids = _FakeJsProxy(["run-a"]) - monkeypatch.setattr(builtins, "_narada_request_id", "parent-request-123", raising=False) + monkeypatch.setattr( + builtins, "_narada_request_id", "parent-request-123", raising=False + ) window = window_module.LocalBrowserWindow() response = await window.dispatch_request(prompt="child prompt") @@ -784,7 +792,9 @@ async def test_local_browser_window_extension_action_includes_parent_request_id( ) _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) window_module._narada_parent_run_ids = _FakeJsProxy(["run-a"]) - monkeypatch.setattr(builtins, "_narada_request_id", "parent-request-123", raising=False) + monkeypatch.setattr( + builtins, "_narada_request_id", "parent-request-123", raising=False + ) window = window_module.LocalBrowserWindow() await window.close() From d5182a0994fb9810cd6093b0b4da0dafba674ed8 Mon Sep 17 00:00:00 2001 From: sebzhao Date: Tue, 26 May 2026 17:16:05 -0700 Subject: [PATCH 11/13] fix: comments --- .../src/narada_core/actions/models.py | 9 ++-- .../narada-core/src/narada_core/models.py | 3 +- .../src/narada_core/tracing/model.py | 11 +---- packages/narada-pyodide/src/narada/window.py | 4 ++ .../tests/test_cloud_browser.py | 49 +++++++++++++++++++ packages/narada/src/narada/window.py | 2 + packages/narada/tests/test_cloud_browser.py | 34 +++++++++++++ 7 files changed, 98 insertions(+), 14 deletions(-) diff --git a/packages/narada-core/src/narada_core/actions/models.py b/packages/narada-core/src/narada_core/actions/models.py index fa59ae8..2ab187d 100644 --- a/packages/narada-core/src/narada_core/actions/models.py +++ b/packages/narada-core/src/narada_core/actions/models.py @@ -12,10 +12,7 @@ override, ) -from pydantic import ( - BaseModel, - Field, -) +from pydantic import BaseModel, ConfigDict, Field from narada_core.tracing import model as tracing_model @@ -50,6 +47,8 @@ class CriticResult(BaseModel): class AgentResponse(BaseModel, Generic[_StructuredOutputT]): + model_config = ConfigDict(populate_by_name=True) + request_id: str status: Literal["success", "error", "input-required"] text: str @@ -60,6 +59,7 @@ class AgentResponse(BaseModel, Generic[_StructuredOutputT]): ] usage: AgentUsage action_trace: tracing_model.ActionTrace | None = None + workflow_trace: dict[str, Any] | None = Field(default=None, alias="workflowTrace") critic_result: CriticResult | None = None @@ -446,4 +446,5 @@ class ExtensionActionResponse(BaseModel): status: Literal["success", "error", "aborted"] error: str | None = None data: str | None = None + action_trace: tracing_model.ActionTrace | None = None workflowTrace: dict[str, Any] | None = None diff --git a/packages/narada-core/src/narada_core/models.py b/packages/narada-core/src/narada_core/models.py index 4685dc5..af468a2 100644 --- a/packages/narada-core/src/narada_core/models.py +++ b/packages/narada-core/src/narada_core/models.py @@ -1,7 +1,7 @@ from __future__ import annotations from enum import Enum, StrEnum -from typing import Annotated, Generic, Literal, NotRequired, TypedDict, TypeVar +from typing import Annotated, Any, Generic, Literal, NotRequired, TypedDict, TypeVar from pydantic import BaseModel, Field @@ -399,6 +399,7 @@ class ResponseContent(TypedDict, Generic[_MaybeStructuredOutput]): text: str structuredOutput: _MaybeStructuredOutput actionTrace: NotRequired[ActionTrace] + workflowTrace: NotRequired[dict[str, Any]] class Usage(TypedDict): diff --git a/packages/narada-core/src/narada_core/tracing/model.py b/packages/narada-core/src/narada_core/tracing/model.py index 4e7b85d..538ec41 100644 --- a/packages/narada-core/src/narada_core/tracing/model.py +++ b/packages/narada-core/src/narada_core/tracing/model.py @@ -2,15 +2,7 @@ from typing import Annotated, Any, Literal -from pydantic import ( - BaseModel, - Field, - NonNegativeInt, - TypeAdapter, - ValidationError, - field_validator, - model_validator, -) +from pydantic import BaseModel, Field, NonNegativeInt, TypeAdapter, ValidationError, field_validator, model_validator def _normalize_agent_type(agent_type: object) -> str: @@ -189,6 +181,7 @@ class RunCustomAgentTrace(BaseModel): workflow_name: str status: Literal["success", "error"] error_message: str | None = None + subWorkflow: dict[str, Any] | None = None children: ActionTrace | None = None diff --git a/packages/narada-pyodide/src/narada/window.py b/packages/narada-pyodide/src/narada/window.py index 4032889..32ac860 100644 --- a/packages/narada-pyodide/src/narada/window.py +++ b/packages/narada-pyodide/src/narada/window.py @@ -705,6 +705,9 @@ async def agent( if action_trace_raw is not None else None ) + workflow_trace = response_content.get("workflowTrace") + if workflow_trace is not None: + _trace.emit_sub_workflow(workflow_trace=workflow_trace) critic_result: CriticResult | None = None if critic is not None: @@ -726,6 +729,7 @@ async def agent( structured_output=response_content.get("structuredOutput"), usage=AgentUsage.model_validate(remote_dispatch_response["usage"]), action_trace=action_trace, + workflow_trace=workflow_trace, critic_result=critic_result, ) diff --git a/packages/narada-pyodide/tests/test_cloud_browser.py b/packages/narada-pyodide/tests/test_cloud_browser.py index da7395e..c5b4081 100644 --- a/packages/narada-pyodide/tests/test_cloud_browser.py +++ b/packages/narada-pyodide/tests/test_cloud_browser.py @@ -348,6 +348,55 @@ async def test_window_agent_keeps_parent_request_id_from_injected_builtins( assert payload["parentRequestId"] == "parent-request-123" +@pytest.mark.asyncio +async def test_window_agent_exposes_workflow_trace_alias( + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow_trace = {"step_type": "workflow", "children": []} + pyfetch = AsyncMock( + side_effect=[ + _FakeResponse(json_data={"requestId": "child-request-123"}), + _FakeResponse( + json_data={ + "status": "success", + "response": { + "text": "done", + "output": {"type": "text", "content": "done"}, + "workflowTrace": 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") + + assert response.workflow_trace == workflow_trace + assert response.model_dump(by_alias=True)["workflowTrace"] == 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": 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 bf0663a..20ed0d2 100644 --- a/packages/narada/src/narada/window.py +++ b/packages/narada/src/narada/window.py @@ -641,6 +641,7 @@ async def agent( if action_trace_raw is not None else None ) + workflow_trace = response_content.get("workflowTrace") critic_result: CriticResult | None = None if critic is not None: @@ -662,6 +663,7 @@ async def agent( structured_output=response_content.get("structuredOutput"), usage=AgentUsage.model_validate(remote_dispatch_response["usage"]), action_trace=action_trace, + workflow_trace=workflow_trace, critic_result=critic_result, ) diff --git a/packages/narada/tests/test_cloud_browser.py b/packages/narada/tests/test_cloud_browser.py index db54062..9e5f6f5 100644 --- a/packages/narada/tests/test_cloud_browser.py +++ b/packages/narada/tests/test_cloud_browser.py @@ -4,6 +4,7 @@ import pytest from narada.client import Narada from narada.config import BrowserConfig +from narada.window import CloudBrowserWindow from narada_core.errors import NaradaTimeoutError @@ -171,3 +172,36 @@ async def test_initialize_cloud_browser_window_uses_domcontentloaded_for_retry_n ] assert wait_for_browser_window_id.await_count == 2 assert window.browser_window_id == "browser-window-123" + + +@pytest.mark.asyncio +async def test_window_agent_exposes_workflow_trace_alias( + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow_trace = {"step_type": "workflow", "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( + return_value={ + "requestId": "request-123", + "status": "success", + "response": { + "text": "done", + "output": {"type": "text", "content": "done"}, + "workflowTrace": workflow_trace, + }, + "usage": {"actions": 0, "credits": 0}, + } + ), + ) + + response = await window.agent(prompt="return a trace") + + assert response.workflow_trace == workflow_trace + assert response.model_dump(by_alias=True)["workflowTrace"] == workflow_trace From 26dc4fda0300426f3880b25eb80cf2b52c93d976 Mon Sep 17 00:00:00 2001 From: sebzhao Date: Tue, 26 May 2026 17:16:20 -0700 Subject: [PATCH 12/13] style: lint --- packages/narada-core/src/narada_core/tracing/model.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/narada-core/src/narada_core/tracing/model.py b/packages/narada-core/src/narada_core/tracing/model.py index 538ec41..b425c32 100644 --- a/packages/narada-core/src/narada_core/tracing/model.py +++ b/packages/narada-core/src/narada_core/tracing/model.py @@ -2,7 +2,15 @@ from typing import Annotated, Any, Literal -from pydantic import BaseModel, Field, NonNegativeInt, TypeAdapter, ValidationError, field_validator, model_validator +from pydantic import ( + BaseModel, + Field, + NonNegativeInt, + TypeAdapter, + ValidationError, + field_validator, + model_validator, +) def _normalize_agent_type(agent_type: object) -> str: From 238193368966daeaebebd10179190ad620ad312a Mon Sep 17 00:00:00 2001 From: sebzhao Date: Wed, 27 May 2026 14:34:50 -0700 Subject: [PATCH 13/13] fix: deduplicate workflows --- packages/narada-pyodide/src/narada/window.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/narada-pyodide/src/narada/window.py b/packages/narada-pyodide/src/narada/window.py index d19eb84..c69444b 100644 --- a/packages/narada-pyodide/src/narada/window.py +++ b/packages/narada-pyodide/src/narada/window.py @@ -24,10 +24,10 @@ from narada_core.actions.critic import run_critic from narada_core.actions.models import ( DEFAULT_HITL_TIMEOUT_SECONDS, - AgenticMouseAction, - AgenticMouseActionRequest, AgenticMatchingSelectorsFinderRequest, AgenticMatchingSelectorsFinderResponse, + AgenticMouseAction, + AgenticMouseActionRequest, AgenticSelectorAction, AgenticSelectorRequest, AgenticSelectorResponse, @@ -708,7 +708,10 @@ async def agent( else None ) workflow_trace = response_content.get("workflowTrace") - if workflow_trace is not None: + 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