diff --git a/examples/human_in_the_loop.py b/examples/human_in_the_loop.py index 3bcf12a..96684cb 100644 --- a/examples/human_in_the_loop.py +++ b/examples/human_in_the_loop.py @@ -1,8 +1,9 @@ import asyncio -from narada import Agent, Narada, UserAbortedError from narada_core.actions.models import PromptForUserInputVariable +from narada import Agent, Narada, UserAbortedError + async def main() -> None: async with Narada() as narada: diff --git a/packages/narada-core/src/narada_core/actions/models.py b/packages/narada-core/src/narada_core/actions/models.py index 4b22ad5..900529f 100644 --- a/packages/narada-core/src/narada_core/actions/models.py +++ b/packages/narada-core/src/narada_core/actions/models.py @@ -433,6 +433,17 @@ class UserApprovalResponse(BaseModel): approved: bool +ActiveInputAction = Annotated[ + PromptForUserInputRequest | UserApprovalRequest, + Field(discriminator="name"), +] + + +class ActiveInputRequest(BaseModel): + input_id: str = Field(alias="inputId") + action: ActiveInputAction + + type ExtensionActionRequest = ( AgenticSelectorRequest | AgenticMatchingSelectorsFinderRequest diff --git a/packages/narada-core/src/narada_core/models.py b/packages/narada-core/src/narada_core/models.py index af468a2..f5d2ee3 100644 --- a/packages/narada-core/src/narada_core/models.py +++ b/packages/narada-core/src/narada_core/models.py @@ -5,6 +5,8 @@ from pydantic import BaseModel, Field +from narada_core.actions.models import ActiveInputRequest + class Agent(Enum): PRODUCTIVITY = 1 @@ -395,8 +397,19 @@ class PythonAgentRunTrace(TypedDict): type ActionTrace = OperatorActionTrace | APAActionTrace +class TextResponseOutput(TypedDict): + type: Literal["text"] + content: str + + +class StructuredResponseOutput(TypedDict, Generic[_MaybeStructuredOutput]): + type: Literal["structured"] + content: _MaybeStructuredOutput + + class ResponseContent(TypedDict, Generic[_MaybeStructuredOutput]): text: str + output: TextResponseOutput | StructuredResponseOutput[_MaybeStructuredOutput] structuredOutput: _MaybeStructuredOutput actionTrace: NotRequired[ActionTrace] workflowTrace: NotRequired[dict[str, Any]] @@ -409,11 +422,22 @@ class Usage(TypedDict): class Response(TypedDict, Generic[_MaybeStructuredOutput]): requestId: str - status: Literal["success", "error"] + status: Literal["success", "error", "input-required"] response: ResponseContent[_MaybeStructuredOutput] | None createdAt: str completedAt: str | None usage: Usage + activeInputRequest: ActiveInputRequest | None + + +class _RemoteDispatchPollResponse(TypedDict): + requestId: str + status: Literal["pending", "input-required", "success", "error"] + response: dict[str, Any] | None + createdAt: str + completedAt: str | None + usage: Usage | None + activeInputRequest: ActiveInputRequest | None class File(TypedDict): diff --git a/packages/narada-core/src/narada_core/tracing/model.py b/packages/narada-core/src/narada_core/tracing/model.py index b425c32..8fcdc50 100644 --- a/packages/narada-core/src/narada_core/tracing/model.py +++ b/packages/narada-core/src/narada_core/tracing/model.py @@ -260,7 +260,7 @@ class PythonSubAgentCallEvent(BaseModel): ts_end: int agent_type: str prompt: str - status: Literal["success", "error", "timeout"] + status: Literal["success", "error", "timeout", "input-required"] request_id: str | None = None text: str | None = None error_message: str | None = None diff --git a/packages/narada-pyodide/src/narada/__init__.py b/packages/narada-pyodide/src/narada/__init__.py index 1c5c77b..bfe0b01 100644 --- a/packages/narada-pyodide/src/narada/__init__.py +++ b/packages/narada-pyodide/src/narada/__init__.py @@ -1,4 +1,4 @@ -from narada_core.actions.models import CriticResult +from narada_core.actions.models import ActiveInputRequest, CriticResult from narada_core.errors import ( NaradaError, NaradaTimeoutError, @@ -23,6 +23,7 @@ __all__ = [ "__version__", + "ActiveInputRequest", "Agent", "CloudBrowserWindow", "CriticConfig", diff --git a/packages/narada-pyodide/src/narada/_trace.py b/packages/narada-pyodide/src/narada/_trace.py index 1c08718..cef5ef8 100644 --- a/packages/narada-pyodide/src/narada/_trace.py +++ b/packages/narada-pyodide/src/narada/_trace.py @@ -75,7 +75,7 @@ def dump_model(model: BaseModel) -> dict[str, Any]: # --------------------------------------------------------------------------- -SubAgentCallStatus = Literal["success", "error", "timeout"] +SubAgentCallStatus = Literal["success", "error", "timeout", "input-required"] ExtensionActionStatus = Literal["success", "error", "timeout"] SideEffectType = Literal["download_file", "render_html"] diff --git a/packages/narada-pyodide/src/narada/window.py b/packages/narada-pyodide/src/narada/window.py index c69444b..9acd079 100644 --- a/packages/narada-pyodide/src/narada/window.py +++ b/packages/narada-pyodide/src/narada/window.py @@ -1,5 +1,6 @@ import asyncio import builtins +import inspect import json import logging import os @@ -11,6 +12,8 @@ IO, TYPE_CHECKING, Any, + Awaitable, + Callable, Literal, Optional, TypeVar, @@ -24,6 +27,7 @@ from narada_core.actions.critic import run_critic from narada_core.actions.models import ( DEFAULT_HITL_TIMEOUT_SECONDS, + ActiveInputRequest, AgenticMatchingSelectorsFinderRequest, AgenticMatchingSelectorsFinderResponse, AgenticMouseAction, @@ -78,6 +82,7 @@ RemoteDispatchChatHistoryItem, Response, UserResourceCredentials, + _RemoteDispatchPollResponse, ) from narada_core.tracing.model import parse_action_trace from pydantic import BaseModel @@ -123,6 +128,36 @@ async def _narada_get_id_token() -> str: ... _ResponseModel = TypeVar("_ResponseModel", bound=BaseModel) +# Optional remote-dispatch context. In frontend Pyodide runs, these are generated +# by prepare-code.ts; extension-action calls forward them so the parent request +# can report active input-required status. +_REMOTE_DISPATCH_REQUEST_ID_ENV_VAR = "NARADA_REMOTE_DISPATCH_REQUEST_ID" +_REMOTE_DISPATCH_API_KEY_ID_ENV_VAR = "NARADA_REMOTE_DISPATCH_API_KEY_ID" + +type InputRequiredCallback = Callable[[ActiveInputRequest], Awaitable[None] | None] + + +async def _notify_input_required_callback( + callback: InputRequiredCallback | None, + response: _RemoteDispatchPollResponse, + seen_input_ids: set[str], +) -> None: + if callback is None or response.get("status") != "input-required": + return + + active_input_request_data = response.get("activeInputRequest") + if active_input_request_data is None: + return + + active_input_request = ActiveInputRequest.model_validate(active_input_request_data) + if active_input_request.input_id in seen_input_ids: + return + + seen_input_ids.add(active_input_request.input_id) + callback_result = callback(active_input_request) + if inspect.isawaitable(callback_result): + await callback_result + def _trace_agent_type(agent: Agent | str) -> str: match agent: @@ -271,6 +306,7 @@ async def dispatch_request( callback_url: str | None = None, callback_secret: str | None = None, callback_headers: dict[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, timeout: int = 1000, ) -> Response[None]: ... @@ -295,6 +331,7 @@ async def dispatch_request( callback_url: str | None = None, callback_secret: str | None = None, callback_headers: dict[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, timeout: int = 1000, ) -> Response[_StructuredOutput]: ... @@ -319,6 +356,7 @@ async def dispatch_request( callback_url: str | None = None, callback_secret: str | None = None, callback_headers: dict[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, timeout: int = 1000, ) -> Response[None]: ... @@ -343,6 +381,7 @@ async def dispatch_request( callback_url: str | None = None, callback_secret: str | None = None, callback_headers: dict[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, timeout: int = 1000, ) -> Response[_StructuredOutput]: ... @@ -367,6 +406,7 @@ async def dispatch_request( callback_url: str | None = None, callback_secret: str | None = None, callback_headers: dict[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, timeout: int = 1000, ) -> Response: """Low-level API for invoking an agent in the Narada extension side panel chat. @@ -445,6 +485,7 @@ async def dispatch_request( body["reasoningMode"] = reasoning.value try: + seen_input_ids: set[str] = set() controller = AbortController.new() signal = controller.signal @@ -484,59 +525,62 @@ async def dispatch_request( text = await fetch_response.text() raise NaradaError(f"Failed to poll for response: {status} {text}") - response = await fetch_response.json() + response: _RemoteDispatchPollResponse = await fetch_response.json() response["requestId"] = request_id - if response["status"] != "pending": - response_content = response["response"] - if response_content is not None: - # Populate the `structuredOutput` field. This is a client-side field - # that's not directly returned by the API. - output_data = response_content.get("output") - if ( - output_schema is not None - and output_data is not None - and output_data.get("type") == "structured" - ): - response_content["structuredOutput"] = ( - output_schema.model_validate(output_data["content"]) - ) - else: - response_content["structuredOutput"] = None - - trace_status: _trace.SubAgentCallStatus = ( - "error" if response["status"] == "error" else "success" - ) - trace_error: str | None = ( - response_content.get("text") - if response["status"] == "error" - and response_content is not None - else None + if response["completedAt"] is None: + await _notify_input_required_callback( + on_input_required, + response, + seen_input_ids, ) - trace_text: str | None = ( - response_content.get("text") - if response["status"] == "success" - and response_content is not None + # Poll every 3 seconds. + await asyncio.sleep(3) + continue + + response_content = response["response"] + if response_content is not None: + # Populate the `structuredOutput` field. This is a client-side field + # that's not directly returned by the API. + output_data = response_content.get("output") + if ( + output_schema is not None + and output_data is not None + and output_data.get("type") == "structured" + ): + response_content["structuredOutput"] = ( + output_schema.model_validate(output_data["content"]) + ) + else: + response_content["structuredOutput"] = None + + trace_status = cast(_trace.SubAgentCallStatus, response["status"]) + trace_error: str | None = ( + response_content.get("text") + if response["status"] == "error" and response_content is not None + else None + ) + trace_text: str | None = ( + response_content.get("text") + if response["status"] in ("success", "input-required") + and response_content is not None + else None + ) + _trace.emit_sub_agent_call( + ts_start=trace_start_ms, + agent_type=agent_type_str, + prompt=prompt, + status=trace_status, + request_id=request_id, + text=trace_text, + error_message=trace_error, + action_trace_raw=( + response_content.get("actionTrace") + if response_content is not None else None - ) - _trace.emit_sub_agent_call( - ts_start=trace_start_ms, - agent_type=agent_type_str, - prompt=prompt, - status=trace_status, - request_id=request_id, - text=trace_text, - error_message=trace_error, - action_trace_raw=( - response_content.get("actionTrace") - if response_content is not None - else None - ), - ) - return response - - # Poll every 3 seconds. - await asyncio.sleep(3) + ), + ) + return cast(Response, response) else: raise NaradaAgentTimeoutError_INTERNAL_DO_NOT_USE(timeout) @@ -584,6 +628,7 @@ async def agent( mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, input_variables: dict[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, timeout: int = 1000, ) -> AgentResponse[dict[str, Any]]: ... @@ -601,6 +646,7 @@ async def agent( mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, input_variables: dict[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, timeout: int = 1000, ) -> AgentResponse[_StructuredOutput]: ... @@ -617,6 +663,7 @@ async def agent( mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, input_variables: dict[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, critic: CriticConfig | None = None, timeout: int = 1000, ) -> AgentResponse[dict[str, Any]]: ... @@ -634,6 +681,7 @@ async def agent( mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, input_variables: dict[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, critic: CriticConfig | None = None, timeout: int = 1000, ) -> AgentResponse[_StructuredOutput]: ... @@ -651,6 +699,7 @@ async def agent( mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, input_variables: dict[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, critic: CriticConfig | None = None, timeout: int = 1000, ) -> AgentResponse: @@ -670,6 +719,7 @@ async def agent( mcp_servers=mcp_servers, secret_variables=secret_variables, input_variables=input_variables, + on_input_required=on_input_required, timeout=timeout, ) else: @@ -696,6 +746,7 @@ async def agent( mcp_servers=mcp_servers, secret_variables=secret_variables, input_variables=input_variables, + on_input_required=on_input_required, timeout=timeout, ) response_content = remote_dispatch_response["response"] @@ -1030,6 +1081,16 @@ async def _run_extension_action( "action": request.model_dump(), "browserWindowId": self.browser_window_id, } + remote_dispatch_request_id = os.environ.get( + _REMOTE_DISPATCH_REQUEST_ID_ENV_VAR + ) + if remote_dispatch_request_id is not None: + body["requestId"] = remote_dispatch_request_id + remote_dispatch_api_key_id = os.environ.get( + _REMOTE_DISPATCH_API_KEY_ID_ENV_VAR + ) + if remote_dispatch_api_key_id is not None: + body["apiKeyId"] = remote_dispatch_api_key_id parent_run_ids = self._current_parent_run_ids() if parent_run_ids: body["parentRunIds"] = parent_run_ids diff --git a/packages/narada-pyodide/tests/test_cloud_browser.py b/packages/narada-pyodide/tests/test_cloud_browser.py index 7098da3..204d0a2 100644 --- a/packages/narada-pyodide/tests/test_cloud_browser.py +++ b/packages/narada-pyodide/tests/test_cloud_browser.py @@ -309,7 +309,14 @@ async def test_cloud_browser_window_dispatch_request_omits_parent_run_ids( pyfetch = AsyncMock( side_effect=[ _FakeResponse(json_data={"requestId": "req-123"}), - _FakeResponse(json_data={"status": "success", "response": None}), + _FakeResponse( + json_data={ + "status": "success", + "completedAt": "2026-05-08T00:00:00+00:00", + "response": None, + "activeInputRequest": None, + } + ), ] ) _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) @@ -333,6 +340,93 @@ 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_waits_through_active_input_required( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pyfetch = AsyncMock( + side_effect=[ + _FakeResponse(json_data={"requestId": "req-123"}), + _FakeResponse( + json_data={ + "status": "input-required", + "completedAt": None, + "response": None, + "activeInputRequest": { + "inputId": "input-123", + "action": { + "name": "prompt_for_user_input", + "step_id": "step-123", + "variables": [ + { + "name": "email", + "type": "string", + "required": True, + } + ], + }, + }, + } + ), + _FakeResponse( + json_data={ + "status": "input-required", + "completedAt": None, + "response": None, + "activeInputRequest": { + "inputId": "input-123", + "action": { + "name": "prompt_for_user_input", + "step_id": "step-123", + "variables": [ + { + "name": "email", + "type": "string", + "required": True, + } + ], + }, + }, + } + ), + _FakeResponse( + json_data={ + "status": "success", + "completedAt": "2026-05-08T00:00:00+00:00", + "response": None, + "activeInputRequest": None, + } + ), + ] + ) + _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) + sleep = AsyncMock() + on_input_required = AsyncMock() + monkeypatch.setattr(window_module.asyncio, "sleep", sleep) + + 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", + on_input_required=on_input_required, + ) + + assert response["status"] == "success" + assert pyfetch.await_count == 4 + assert sleep.await_count == 2 + on_input_required.assert_awaited_once() + active_input_request = on_input_required.await_args.args[0] + assert active_input_request.input_id == "input-123" + assert active_input_request.action.name == "prompt_for_user_input" + first_poll_call = pyfetch.await_args_list[1] + second_poll_call = pyfetch.await_args_list[3] + assert first_poll_call.args[0].endswith("/remote-dispatch/responses/req-123") + assert second_poll_call.args[0].endswith("/remote-dispatch/responses/req-123") + + @pytest.mark.asyncio async def test_cloud_browser_window_dispatch_request_keeps_parent_request_id( monkeypatch: pytest.MonkeyPatch, @@ -340,7 +434,14 @@ async def test_cloud_browser_window_dispatch_request_keeps_parent_request_id( pyfetch = AsyncMock( side_effect=[ _FakeResponse(json_data={"requestId": "child-request-123"}), - _FakeResponse(json_data={"status": "success", "response": None}), + _FakeResponse( + json_data={ + "status": "success", + "completedAt": "2026-05-08T00:00:00+00:00", + "response": None, + "activeInputRequest": None, + } + ), ] ) _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) @@ -377,7 +478,9 @@ async def test_window_agent_keeps_parent_request_id_from_injected_builtins( "text": "done", "output": {"type": "text", "content": "done"}, }, + "completedAt": "2026-05-08T00:00:00+00:00", "usage": {"actions": 0, "credits": 0}, + "activeInputRequest": None, } ), ] @@ -417,7 +520,9 @@ async def test_window_agent_exposes_workflow_trace_alias( "output": {"type": "text", "content": "done"}, "workflowTrace": workflow_trace, }, + "completedAt": "2026-05-08T00:00:00+00:00", "usage": {"actions": 0, "credits": 0}, + "activeInputRequest": None, } ), ] @@ -459,7 +564,14 @@ async def test_cloud_browser_window_dispatch_request_retries_poll_fetch_failures _FakeResponse(json_data={"requestId": "req-123"}), RuntimeError("temporary fetch failure"), _FakeResponse(ok=False, status=502, text_data="bad gateway"), - _FakeResponse(json_data={"status": "success", "response": None}), + _FakeResponse( + json_data={ + "status": "success", + "completedAt": "2026-05-08T00:00:00+00:00", + "response": None, + "activeInputRequest": None, + } + ), ] ) _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) @@ -514,7 +626,14 @@ async def test_dispatch_request_emits_string_trace_agent_type_for_sdk_enum( pyfetch = AsyncMock( side_effect=[ _FakeResponse(json_data={"requestId": "req-123"}), - _FakeResponse(json_data={"status": "success", "response": None}), + _FakeResponse( + json_data={ + "status": "success", + "completedAt": "2026-05-08T00:00:00+00:00", + "response": None, + "activeInputRequest": None, + } + ), ] ) narada_pkg, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) @@ -554,10 +673,12 @@ async def test_dispatch_request_emits_success_text_in_sub_agent_trace( _FakeResponse( json_data={ "status": "success", + "completedAt": "2026-05-08T00:00:00+00:00", "response": { "text": "TRACE_CORE_AGENT_DONE", "actionTrace": [], }, + "activeInputRequest": None, } ), ] @@ -592,6 +713,51 @@ async def test_dispatch_request_emits_success_text_in_sub_agent_trace( assert parsed_event.action_trace == [] +@pytest.mark.asyncio +async def test_dispatch_request_emits_input_required_sub_agent_trace( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pyfetch = AsyncMock( + side_effect=[ + _FakeResponse(json_data={"requestId": "req-123"}), + _FakeResponse( + json_data={ + "status": "input-required", + "completedAt": "2026-05-08T00:00:00+00:00", + "response": { + "text": "TRACE_INPUT_REQUIRED", + "output": {"type": "text", "content": "TRACE_INPUT_REQUIRED"}, + }, + "activeInputRequest": None, + } + ), + ] + ) + _, _, 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.dispatch_request(prompt="needs input") + + from narada_core.tracing.model import PythonSubAgentCallEvent + + assert response["status"] == "input-required" + assert len(emitted_events) == 1 + parsed_event = PythonSubAgentCallEvent.model_validate(json.loads(emitted_events[0])) + assert parsed_event.status == "input-required" + assert parsed_event.text == "TRACE_INPUT_REQUIRED" + + def test_parse_action_trace_preserves_run_custom_agent_children( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -631,7 +797,14 @@ async def test_cloud_browser_window_dispatch_request_preserves_current_file_vari pyfetch = AsyncMock( side_effect=[ _FakeResponse(json_data={"requestId": "req-123"}), - _FakeResponse(json_data={"status": "success", "response": None}), + _FakeResponse( + json_data={ + "status": "success", + "completedAt": "2026-05-08T00:00:00+00:00", + "response": None, + "activeInputRequest": None, + } + ), ] ) _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) @@ -797,6 +970,30 @@ async def test_remote_browser_window_without_cloud_session_keeps_extension_actio assert "parentRunIds" not in payload +@pytest.mark.asyncio +async def test_extension_action_includes_remote_dispatch_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("NARADA_REMOTE_DISPATCH_REQUEST_ID", "request-123") + monkeypatch.setenv("NARADA_REMOTE_DISPATCH_API_KEY_ID", "api-key-123") + pyfetch = AsyncMock( + return_value=_FakeResponse(json_data={"status": "success", "data": None}) + ) + _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) + + 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"] == "request-123" + assert payload["apiKeyId"] == "api-key-123" + + @pytest.mark.asyncio async def test_remote_browser_window_extension_action_keeps_parent_request_id( monkeypatch: pytest.MonkeyPatch, @@ -832,9 +1029,23 @@ async def test_local_browser_window_dispatch_request_uses_latest_parent_run_ids( pyfetch = AsyncMock( side_effect=[ _FakeResponse(json_data={"requestId": "req-1"}), - _FakeResponse(json_data={"status": "success", "response": None}), + _FakeResponse( + json_data={ + "status": "success", + "completedAt": "2026-05-08T00:00:00+00:00", + "response": None, + "activeInputRequest": None, + } + ), _FakeResponse(json_data={"requestId": "req-2"}), - _FakeResponse(json_data={"status": "success", "response": None}), + _FakeResponse( + json_data={ + "status": "success", + "completedAt": "2026-05-08T00:00:00+00:00", + "response": None, + "activeInputRequest": None, + } + ), ] ) _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) @@ -865,7 +1076,14 @@ async def test_local_browser_window_dispatch_request_includes_parent_request_id( pyfetch = AsyncMock( side_effect=[ _FakeResponse(json_data={"requestId": "child-request-123"}), - _FakeResponse(json_data={"status": "success", "response": None}), + _FakeResponse( + json_data={ + "status": "success", + "completedAt": "2026-05-08T00:00:00+00:00", + "response": None, + "activeInputRequest": None, + } + ), ] ) _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) diff --git a/packages/narada/src/narada/__init__.py b/packages/narada/src/narada/__init__.py index 719f28d..bf9c3a2 100644 --- a/packages/narada/src/narada/__init__.py +++ b/packages/narada/src/narada/__init__.py @@ -1,4 +1,4 @@ -from narada_core.actions.models import CriticResult +from narada_core.actions.models import ActiveInputRequest, CriticResult from narada_core.errors import ( NaradaError, NaradaExtensionMissingError, @@ -25,6 +25,7 @@ __all__ = [ "__version__", + "ActiveInputRequest", "Agent", "BrowserConfig", "CloudBrowserWindow", diff --git a/packages/narada/src/narada/window.py b/packages/narada/src/narada/window.py index e5b963a..b1eb2f2 100644 --- a/packages/narada/src/narada/window.py +++ b/packages/narada/src/narada/window.py @@ -1,4 +1,5 @@ import asyncio +import inspect import logging import mimetypes import os @@ -11,11 +12,14 @@ from typing import ( IO, Any, + Awaitable, + Callable, Literal, Mapping, TypedDict, TypeGuard, TypeVar, + cast, overload, override, ) @@ -24,6 +28,7 @@ from narada_core.actions.critic import run_critic from narada_core.actions.models import ( DEFAULT_HITL_TIMEOUT_SECONDS, + ActiveInputRequest, AgenticMatchingSelectorsFinderRequest, AgenticMatchingSelectorsFinderResponse, AgenticMouseAction, @@ -78,6 +83,7 @@ RemoteDispatchChatHistoryItem, Response, UserResourceCredentials, + _RemoteDispatchPollResponse, ) from narada_core.tracing.model import parse_action_trace from playwright.async_api import ( @@ -94,6 +100,36 @@ _ResponseModel = TypeVar("_ResponseModel", bound=BaseModel) +# Optional remote-dispatch context. In frontend Pyodide runs, these are generated +# by prepare-code.ts; extension-action calls forward them so the parent request +# can report active input-required status. +_REMOTE_DISPATCH_REQUEST_ID_ENV_VAR = "NARADA_REMOTE_DISPATCH_REQUEST_ID" +_REMOTE_DISPATCH_API_KEY_ID_ENV_VAR = "NARADA_REMOTE_DISPATCH_API_KEY_ID" + +type InputRequiredCallback = Callable[[ActiveInputRequest], Awaitable[None] | None] + + +async def _notify_input_required_callback( + callback: InputRequiredCallback | None, + response: _RemoteDispatchPollResponse, + seen_input_ids: set[str], +) -> None: + if callback is None or response.get("status") != "input-required": + return + + active_input_request_data = response.get("activeInputRequest") + if active_input_request_data is None: + return + + active_input_request = ActiveInputRequest.model_validate(active_input_request_data) + if active_input_request.input_id in seen_input_ids: + return + + seen_input_ids.add(active_input_request.input_id) + callback_result = callback(active_input_request) + if inspect.isawaitable(callback_result): + await callback_result + class _InputVariableFileReference(TypedDict): source: Literal["remoteDispatchUpload"] @@ -281,6 +317,7 @@ async def dispatch_request( callback_url: str | None = None, callback_secret: str | None = None, callback_headers: Mapping[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, timeout: int = 1000, ) -> Response[None]: ... @@ -306,6 +343,7 @@ async def dispatch_request( callback_url: str | None = None, callback_secret: str | None = None, callback_headers: Mapping[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, timeout: int = 1000, ) -> Response[_StructuredOutput]: ... @@ -331,6 +369,7 @@ async def dispatch_request( callback_url: str | None = None, callback_secret: str | None = None, callback_headers: Mapping[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, timeout: int = 1000, ) -> Response[None]: ... @@ -356,6 +395,7 @@ async def dispatch_request( callback_url: str | None = None, callback_secret: str | None = None, callback_headers: Mapping[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, timeout: int = 1000, ) -> Response[_StructuredOutput]: ... @@ -381,6 +421,7 @@ async def dispatch_request( callback_url: str | None = None, callback_secret: str | None = None, callback_headers: Mapping[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, timeout: int = 1000, ) -> Response: """Low-level API for invoking an agent in the Narada extension side panel chat. @@ -449,6 +490,7 @@ async def dispatch_request( body["reasoningMode"] = reasoning.value try: + seen_input_ids: set[str] = set() async with aiohttp.ClientSession() as session: async with session.post( f"{self._base_url}/remote-dispatch", @@ -466,31 +508,37 @@ async def dispatch_request( timeout=aiohttp.ClientTimeout(total=deadline - now), ) as resp: resp.raise_for_status() - response = await resp.json() + response: _RemoteDispatchPollResponse = await resp.json() response["requestId"] = request_id - if response["status"] != "pending": - response_content = response["response"] - if response_content is not None: - # Populate the `structuredOutput` field. This is a client-side field - # that's not directly returned by the API. - output_data = response_content.get("output") - if ( - output_schema is not None - and output_data is not None - and output_data.get("type") == "structured" - ): - response_content["structuredOutput"] = ( - output_schema.model_validate(output_data["content"]) - ) - else: - response_content["structuredOutput"] = None - - return response - - # Poll every 3 seconds. - await asyncio.sleep(3) + if response["completedAt"] is None: + await _notify_input_required_callback( + on_input_required, + response, + seen_input_ids, + ) + # Poll every 3 seconds. + await asyncio.sleep(3) + continue + + response_content = response["response"] + if response_content is not None: + # Populate the `structuredOutput` field. This is a client-side field + # that's not directly returned by the API. + output_data = response_content.get("output") + if ( + output_schema is not None + and output_data is not None + and output_data.get("type") == "structured" + ): + response_content["structuredOutput"] = ( + output_schema.model_validate(output_data["content"]) + ) + else: + response_content["structuredOutput"] = None + + return cast(Response, response) else: raise NaradaAgentTimeoutError_INTERNAL_DO_NOT_USE(timeout) @@ -514,6 +562,7 @@ async def agent( mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, input_variables: Mapping[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, timeout: int = 1000, ) -> AgentResponse[dict[str, Any]]: ... @@ -532,6 +581,7 @@ async def agent( mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, input_variables: Mapping[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, timeout: int = 1000, ) -> AgentResponse[_StructuredOutput]: ... @@ -549,6 +599,7 @@ async def agent( mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, input_variables: Mapping[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, critic: CriticConfig | None = None, timeout: int = 1000, ) -> AgentResponse[dict[str, Any]]: ... @@ -567,6 +618,7 @@ async def agent( mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, input_variables: Mapping[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, critic: CriticConfig | None = None, timeout: int = 1000, ) -> AgentResponse[_StructuredOutput]: ... @@ -585,6 +637,7 @@ async def agent( mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, input_variables: Mapping[str, Any] | None = None, + on_input_required: InputRequiredCallback | None = None, critic: CriticConfig | None = None, timeout: int = 1000, ) -> AgentResponse: @@ -605,6 +658,7 @@ async def agent( mcp_servers=mcp_servers, secret_variables=secret_variables, input_variables=input_variables, + on_input_required=on_input_required, timeout=timeout, ) else: @@ -632,6 +686,7 @@ async def agent( mcp_servers=mcp_servers, secret_variables=secret_variables, input_variables=input_variables, + on_input_required=on_input_required, timeout=timeout, ) response_content = remote_dispatch_response["response"] @@ -949,6 +1004,12 @@ async def _run_extension_action( "action": request.model_dump(), "browserWindowId": self.browser_window_id, } + remote_dispatch_request_id = os.environ.get(_REMOTE_DISPATCH_REQUEST_ID_ENV_VAR) + if remote_dispatch_request_id is not None: + body["requestId"] = remote_dispatch_request_id + remote_dispatch_api_key_id = os.environ.get(_REMOTE_DISPATCH_API_KEY_ID_ENV_VAR) + if remote_dispatch_api_key_id is not None: + body["apiKeyId"] = remote_dispatch_api_key_id if timeout is not None: body["timeout"] = timeout diff --git a/packages/narada/tests/test_cloud_browser.py b/packages/narada/tests/test_cloud_browser.py index 9e5f6f5..754b01e 100644 --- a/packages/narada/tests/test_cloud_browser.py +++ b/packages/narada/tests/test_cloud_browser.py @@ -4,7 +4,7 @@ import pytest from narada.client import Narada from narada.config import BrowserConfig -from narada.window import CloudBrowserWindow +from narada.window import CloudBrowserWindow, RemoteBrowserWindow from narada_core.errors import NaradaTimeoutError @@ -21,6 +21,9 @@ async def __aenter__(self): async def __aexit__(self, *args): pass + def raise_for_status(self): + return None + async def json(self): return self._payload @@ -44,6 +47,29 @@ def post(self, url: str, **kwargs): return _FakeResponse(self.payload) +class _RemoteDispatchFakeClientSession: + def __init__(self, poll_payloads: list[dict]) -> None: + self.poll_payloads = poll_payloads + self.dispatched_body: dict | None = None + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + def post(self, url: str, **kwargs): + if url.endswith("/remote-dispatch"): + self.dispatched_body = kwargs["json"] + return _FakeResponse({"requestId": "req-123"}) + raise AssertionError(f"Unexpected POST URL: {url}") + + def get(self, url: str, **kwargs): + if url.endswith("/remote-dispatch/responses/req-123"): + return _FakeResponse(self.poll_payloads.pop(0)) + raise AssertionError(f"Unexpected GET URL: {url}") + + def _build_client_with_cloud_page(page: AsyncMock) -> Narada: client = Narada(auth_headers={"x-api-key": "test-key"}) browser = SimpleNamespace(contexts=[SimpleNamespace(pages=[page])]) @@ -53,6 +79,97 @@ def _build_client_with_cloud_page(page: AsyncMock) -> Narada: return client +@pytest.mark.asyncio +async def test_dispatch_request_calls_input_required_callback_once_per_input_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import narada.window as window_module + + fake_session = _RemoteDispatchFakeClientSession( + [ + { + "status": "input-required", + "response": None, + "usage": None, + "createdAt": "2026-01-01T00:00:00Z", + "completedAt": None, + "activeInputRequest": { + "inputId": "input-1", + "action": { + "name": "prompt_for_user_input", + "step_id": "prompt-1", + "variables": [ + {"name": "email", "type": "string", "required": True} + ], + }, + }, + }, + { + "status": "input-required", + "response": None, + "usage": None, + "createdAt": "2026-01-01T00:00:00Z", + "completedAt": None, + "activeInputRequest": { + "inputId": "input-1", + "action": { + "name": "prompt_for_user_input", + "step_id": "prompt-1", + "variables": [ + {"name": "email", "type": "string", "required": True} + ], + }, + }, + }, + { + "status": "input-required", + "response": None, + "usage": None, + "createdAt": "2026-01-01T00:00:00Z", + "completedAt": None, + "activeInputRequest": { + "inputId": "input-2", + "action": { + "name": "user_approval", + "step_id": "approval-1", + "prompt_message": "Approve?", + "approve_label": "Approve", + "reject_label": "Reject", + }, + }, + }, + { + "status": "success", + "response": {"text": "ok"}, + "usage": {"actions": 1, "credits": 1}, + "createdAt": "2026-01-01T00:00:00Z", + "completedAt": "2026-01-01T00:00:01Z", + "activeInputRequest": None, + }, + ] + ) + monkeypatch.setattr(window_module.aiohttp, "ClientSession", lambda: fake_session) + sleep = AsyncMock() + monkeypatch.setattr(window_module.asyncio, "sleep", sleep) + + observed_input_ids: list[str] = [] + + async def on_input_required(active_input_request) -> None: + observed_input_ids.append(active_input_request.input_id) + + window = RemoteBrowserWindow(browser_window_id="bw-1", api_key="test-key") + + response = await window.dispatch_request( + prompt="Summarize", + timeout=5, + on_input_required=on_input_required, + ) + + assert response["status"] == "success" + assert observed_input_ids == ["input-1", "input-2"] + assert sleep.await_count == 3 + + @pytest.mark.asyncio async def test_extensionless_cloud_browser_uses_backend_initialization( monkeypatch: pytest.MonkeyPatch, @@ -196,7 +313,9 @@ async def test_window_agent_exposes_workflow_trace_alias( "output": {"type": "text", "content": "done"}, "workflowTrace": workflow_trace, }, + "completedAt": "2026-01-01T00:00:01Z", "usage": {"actions": 0, "credits": 0}, + "activeInputRequest": None, } ), )