From 7f27c35b9dd88311080e6fab7102da9ae58ffb1c Mon Sep 17 00:00:00 2001 From: Arnav Brahmasandra Date: Mon, 13 Apr 2026 15:36:35 -0700 Subject: [PATCH] python sdk support --- .../src/narada_core/human_interaction.py | 35 +++++ .../narada-core/src/narada_core/models.py | 12 ++ packages/narada-core/src/narada_core/types.py | 24 +++ .../narada-pyodide/src/narada/__init__.py | 14 +- .../src/narada/human_interaction.py | 13 ++ packages/narada-pyodide/src/narada/types.py | 19 +++ packages/narada-pyodide/src/narada/window.py | 68 +++++++- packages/narada/src/narada/__init__.py | 8 + packages/narada/src/narada/client.py | 7 + .../narada/src/narada/human_interaction.py | 145 ++++++++++++++++++ packages/narada/src/narada/types.py | 19 +++ packages/narada/src/narada/window.py | 86 ++++++++--- 12 files changed, 417 insertions(+), 33 deletions(-) create mode 100644 packages/narada-core/src/narada_core/human_interaction.py create mode 100644 packages/narada-core/src/narada_core/types.py create mode 100644 packages/narada-pyodide/src/narada/human_interaction.py create mode 100644 packages/narada-pyodide/src/narada/types.py create mode 100644 packages/narada/src/narada/human_interaction.py create mode 100644 packages/narada/src/narada/types.py diff --git a/packages/narada-core/src/narada_core/human_interaction.py b/packages/narada-core/src/narada_core/human_interaction.py new file mode 100644 index 0000000..a8c2007 --- /dev/null +++ b/packages/narada-core/src/narada_core/human_interaction.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import NotRequired, Protocol, TypedDict + +from narada_core.types import InputVariables, InputVariableValue, PromptVariableType + + +class UserAbortedError(Exception): + """Raised when a human-in-the-loop interaction is cancelled by the user.""" + + +class PromptVariableRequest(TypedDict): + name: str + type: PromptVariableType + required: bool + initial_value: NotRequired[InputVariableValue] + enum_values: NotRequired[list[str]] + + +class HumanInteractionHandler(Protocol): + async def request_user_approval( + self, + *, + step_id: str, + prompt_message: str, + approve_label: str, + reject_label: str, + ) -> bool: ... + + async def prompt_for_user_input( + self, + *, + step_id: str, + variables: list[PromptVariableRequest], + ) -> InputVariables: ... diff --git a/packages/narada-core/src/narada_core/models.py b/packages/narada-core/src/narada_core/models.py index 8e03292..7bd994e 100644 --- a/packages/narada-core/src/narada_core/models.py +++ b/packages/narada-core/src/narada_core/models.py @@ -262,6 +262,16 @@ class ObjectSetPropertiesTrace(TypedDict): description: str +class UserApprovalTrace(TypedDict): + step_type: Literal["userApproval"] + description: str + + +class PromptForUserInputTrace(TypedDict): + step_type: Literal["promptForUserInput"] + description: str + + ApaStepTrace = ( GoToUrlTrace | GetUrlTrace @@ -292,6 +302,8 @@ class ObjectSetPropertiesTrace(TypedDict): | DataTableInsertRowTrace | DataTableUpdateCellValueTrace | ObjectSetPropertiesTrace + | UserApprovalTrace + | PromptForUserInputTrace ) diff --git a/packages/narada-core/src/narada_core/types.py b/packages/narada-core/src/narada_core/types.py new file mode 100644 index 0000000..8088453 --- /dev/null +++ b/packages/narada-core/src/narada_core/types.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from io import IOBase +from typing import Literal + +type JsonPrimitive = str | int | float | bool | None +type InputVariableValue = ( + JsonPrimitive | list["InputVariableValue"] | dict[str, "InputVariableValue"] +) +type InputVariables = dict[str, InputVariableValue] + +# Dispatch input variables additionally support file-like values that get uploaded. +type DispatchInputVariableValue = InputVariableValue | IOBase +type DispatchInputVariables = dict[str, DispatchInputVariableValue] + +type PromptVariableType = Literal[ + "string", + "number", + "boolean", + "enum", + "dataTable", + "object", + "array", +] diff --git a/packages/narada-pyodide/src/narada/__init__.py b/packages/narada-pyodide/src/narada/__init__.py index e2144c1..a050087 100644 --- a/packages/narada-pyodide/src/narada/__init__.py +++ b/packages/narada-pyodide/src/narada/__init__.py @@ -1,21 +1,24 @@ +from narada_core.errors import ( + NaradaError, + NaradaTimeoutError, +) +from narada_core.models import Agent, File, Response, ResponseContent + from narada.client import Narada +from narada.human_interaction import HumanInteractionHandler, UserAbortedError from narada.utils import download_file, render_html from narada.version import __version__ from narada.window import ( LocalBrowserWindow, RemoteBrowserWindow, ) -from narada_core.errors import ( - NaradaError, - NaradaTimeoutError, -) -from narada_core.models import Agent, File, Response, ResponseContent __all__ = [ "__version__", "Agent", "download_file", "File", + "HumanInteractionHandler", "LocalBrowserWindow", "Narada", "NaradaError", @@ -24,4 +27,5 @@ "render_html", "Response", "ResponseContent", + "UserAbortedError", ] diff --git a/packages/narada-pyodide/src/narada/human_interaction.py b/packages/narada-pyodide/src/narada/human_interaction.py new file mode 100644 index 0000000..ea1acfc --- /dev/null +++ b/packages/narada-pyodide/src/narada/human_interaction.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from narada_core.human_interaction import ( + HumanInteractionHandler, + PromptVariableRequest, + UserAbortedError, +) + +__all__ = [ + "HumanInteractionHandler", + "PromptVariableRequest", + "UserAbortedError", +] diff --git a/packages/narada-pyodide/src/narada/types.py b/packages/narada-pyodide/src/narada/types.py new file mode 100644 index 0000000..a658f7a --- /dev/null +++ b/packages/narada-pyodide/src/narada/types.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from narada_core.types import ( + DispatchInputVariables, + DispatchInputVariableValue, + InputVariables, + InputVariableValue, + JsonPrimitive, + PromptVariableType, +) + +__all__ = [ + "DispatchInputVariableValue", + "DispatchInputVariables", + "InputVariableValue", + "InputVariables", + "JsonPrimitive", + "PromptVariableType", +] diff --git a/packages/narada-pyodide/src/narada/window.py b/packages/narada-pyodide/src/narada/window.py index df6bfa1..1237556 100644 --- a/packages/narada-pyodide/src/narada/window.py +++ b/packages/narada-pyodide/src/narada/window.py @@ -48,10 +48,13 @@ Response, UserResourceCredentials, ) +from narada_core.types import DispatchInputVariableValue, InputVariables from pydantic import BaseModel from pyodide.ffi import JsProxy, create_once_callable from pyodide.http import pyfetch +from narada.human_interaction import HumanInteractionHandler, PromptVariableRequest + # Magic variable injected by the JavaScript harness that stores the IDs of the current runnables # in the stack on the frontend. @@ -86,6 +89,7 @@ class BaseBrowserWindow(ABC): _user_id: str | None _env: Literal["prod", "dev", None] _browser_window_id: str + _human_interaction_handler: HumanInteractionHandler | None def __init__( self, @@ -95,6 +99,7 @@ def __init__( user_id: str | None, env: Literal["prod", "dev", None] = "prod", browser_window_id: str, + human_interaction_handler: HumanInteractionHandler | None = None, ) -> None: if api_key is None and (user_id is None or env is None): raise ValueError( @@ -106,6 +111,7 @@ def __init__( self._user_id = user_id self._env = env self._browser_window_id = browser_window_id + self._human_interaction_handler = human_interaction_handler @property def browser_window_id(self) -> str: @@ -137,7 +143,7 @@ async def dispatch_request( user_resource_credentials: UserResourceCredentials | None = None, mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, - input_variables: dict[str, Any] | None = None, + input_variables: dict[str, DispatchInputVariableValue] | None = None, callback_url: str | None = None, callback_secret: str | None = None, callback_headers: dict[str, Any] | None = None, @@ -160,7 +166,7 @@ async def dispatch_request( user_resource_credentials: UserResourceCredentials | None = None, mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, - input_variables: dict[str, Any] | None = None, + input_variables: dict[str, DispatchInputVariableValue] | None = None, callback_url: str | None = None, callback_secret: str | None = None, callback_headers: dict[str, Any] | None = None, @@ -182,7 +188,7 @@ async def dispatch_request( user_resource_credentials: UserResourceCredentials | None = None, mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, - input_variables: dict[str, Any] | None = None, + input_variables: dict[str, DispatchInputVariableValue] | None = None, callback_url: str | None = None, callback_secret: str | None = None, callback_headers: dict[str, Any] | None = None, @@ -327,7 +333,7 @@ async def agent( time_zone: str = "America/Los_Angeles", mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, - input_variables: dict[str, Any] | None = None, + input_variables: dict[str, DispatchInputVariableValue] | None = None, timeout: int = 1000, ) -> AgentResponse[dict[str, Any]]: ... @@ -343,7 +349,7 @@ async def agent( time_zone: str = "America/Los_Angeles", mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, - input_variables: dict[str, Any] | None = None, + input_variables: dict[str, DispatchInputVariableValue] | None = None, timeout: int = 1000, ) -> AgentResponse[_StructuredOutput]: ... @@ -358,7 +364,7 @@ async def agent( time_zone: str = "America/Los_Angeles", mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, - input_variables: dict[str, Any] | None = None, + input_variables: dict[str, DispatchInputVariableValue] | None = None, timeout: int = 1000, ) -> AgentResponse: """Invokes an agent in the Narada extension side panel chat.""" @@ -479,6 +485,42 @@ async def print_message(self, *, message: str, timeout: int | None = None) -> No PrintMessageRequest(message=message), timeout=timeout ) + async def request_user_approval( + self, + *, + step_id: str, + prompt_message: str, + approve_label: str, + reject_label: str, + ) -> bool: + if self._human_interaction_handler is None: + raise NotImplementedError( + "No HumanInteractionHandler configured. Pass " + "`human_interaction_handler=...` when creating the browser window." + ) + return await self._human_interaction_handler.request_user_approval( + step_id=step_id, + prompt_message=prompt_message, + approve_label=approve_label, + reject_label=reject_label, + ) + + async def prompt_for_user_input( + self, + *, + step_id: str, + variables: list[PromptVariableRequest], + ) -> InputVariables: + if self._human_interaction_handler is None: + raise NotImplementedError( + "No HumanInteractionHandler configured. Pass " + "`human_interaction_handler=...` when creating the browser window." + ) + return await self._human_interaction_handler.prompt_for_user_input( + step_id=step_id, + variables=variables, + ) + async def read_google_sheet( self, *, @@ -610,7 +652,9 @@ async def _run_extension_action( class LocalBrowserWindow(BaseBrowserWindow): - def __init__(self) -> None: + def __init__( + self, *, human_interaction_handler: HumanInteractionHandler | None = None + ) -> None: env = os.environ.get("NARADA_ENV") if env is not None and env not in ("prod", "dev"): raise ValueError(f"Invalid environment: {env!r}") @@ -621,6 +665,7 @@ def __init__(self) -> None: user_id=os.environ.get("NARADA_USER_ID"), env=env, browser_window_id=os.environ["NARADA_BROWSER_WINDOW_ID"], + human_interaction_handler=human_interaction_handler, ) def __str__(self) -> str: @@ -628,13 +673,20 @@ def __str__(self) -> str: class RemoteBrowserWindow(BaseBrowserWindow): - def __init__(self, *, browser_window_id: str, api_key: str | None = None) -> None: + def __init__( + self, + *, + browser_window_id: str, + api_key: str | None = None, + human_interaction_handler: HumanInteractionHandler | None = None, + ) -> None: super().__init__( api_key=api_key or os.environ["NARADA_API_KEY"], base_url=os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2"), user_id=None, env=None, browser_window_id=browser_window_id, + human_interaction_handler=human_interaction_handler, ) def __str__(self) -> str: diff --git a/packages/narada/src/narada/__init__.py b/packages/narada/src/narada/__init__.py index 3957d7b..b50f695 100644 --- a/packages/narada/src/narada/__init__.py +++ b/packages/narada/src/narada/__init__.py @@ -10,6 +10,11 @@ from narada.client import Narada from narada.config import BrowserConfig, ProxyConfig +from narada.human_interaction import ( + CliHumanInteractionHandler, + HumanInteractionHandler, + UserAbortedError, +) from narada.utils import download_file, render_html from narada.version import __version__ from narada.window import CloudBrowserWindow, LocalBrowserWindow, RemoteBrowserWindow @@ -19,8 +24,10 @@ "Agent", "BrowserConfig", "CloudBrowserWindow", + "CliHumanInteractionHandler", "download_file", "File", + "HumanInteractionHandler", "LocalBrowserWindow", "Narada", "NaradaError", @@ -34,4 +41,5 @@ "render_html", "Response", "ResponseContent", + "UserAbortedError", ] diff --git a/packages/narada/src/narada/client.py b/packages/narada/src/narada/client.py index 354a690..4eacfa8 100644 --- a/packages/narada/src/narada/client.py +++ b/packages/narada/src/narada/client.py @@ -36,6 +36,7 @@ from rich.console import Console from narada.config import BrowserConfig, ProxyConfig +from narada.human_interaction import HumanInteractionHandler from narada.utils import assert_never, assert_not_none from narada.version import __version__ from narada.window import ( @@ -82,6 +83,7 @@ class Narada: _INITIALIZATION_ERROR_INDICATOR_SELECTOR = "#narada-initialization-error" _auth_headers: dict[str, str] + _human_interaction_handler: HumanInteractionHandler | None _console: Console _playwright_context_manager: PlaywrightContextManager | None = None _playwright: Playwright | None = None @@ -91,12 +93,14 @@ def __init__( *, api_key: str | None = None, auth_headers: dict[str, str] | None = None, + human_interaction_handler: HumanInteractionHandler | None = None, ) -> None: if auth_headers is not None: self._auth_headers = auth_headers else: api_key = api_key or os.environ["NARADA_API_KEY"] self._auth_headers = {"x-api-key": api_key} + self._human_interaction_handler = human_interaction_handler self._console = Console() async def __aenter__(self) -> Narada: @@ -168,6 +172,7 @@ async def open_and_initialize_browser_window( browser_window_id=browser_window_id, config=config, context=side_panel_page.context, + human_interaction_handler=self._human_interaction_handler, ) async def open_and_initialize_cloud_browser_window( @@ -300,6 +305,7 @@ async def _initialize_cloud_browser_window( browser_window_id=browser_window_id, session_id=session_id, auth_headers=self._auth_headers, + human_interaction_handler=self._human_interaction_handler, ) if config.interactive: @@ -364,6 +370,7 @@ async def initialize_in_existing_browser_window( browser_window_id=browser_window_id, config=config, context=context, + human_interaction_handler=self._human_interaction_handler, ) async def _launch_browser( diff --git a/packages/narada/src/narada/human_interaction.py b/packages/narada/src/narada/human_interaction.py new file mode 100644 index 0000000..daa9842 --- /dev/null +++ b/packages/narada/src/narada/human_interaction.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import asyncio +import json +from typing import Callable + +from narada_core.human_interaction import ( + HumanInteractionHandler, + PromptVariableRequest, + UserAbortedError, +) +from narada_core.types import InputVariables, InputVariableValue, PromptVariableType + + +class CliHumanInteractionHandler: + """CLI-backed optional helper implementation for human interaction prompts.""" + + def __init__(self, *, reader: Callable[[str], str] = input) -> None: + # Injectable reader keeps this class easy to test. + self._reader = reader + + async def request_user_approval( + self, + *, + step_id: str, + prompt_message: str, + approve_label: str, + reject_label: str, + ) -> bool: + prompt = ( + f"[{step_id}] {prompt_message}\n" + f"Type '{approve_label}' to approve, '{reject_label}' to reject, or 'cancel' to abort: " + ) + approve_token = approve_label.strip().lower() + reject_token = reject_label.strip().lower() + while True: + value = (await self._read_line(prompt)).strip().lower() + if value == "cancel": + raise UserAbortedError("User approval was cancelled.") + if value == approve_token: + return True + if value == reject_token: + return False + + async def prompt_for_user_input( + self, + *, + step_id: str, + variables: list[PromptVariableRequest], + ) -> InputVariables: + values_by_name: InputVariables = {} + for variable in variables: + value = await self._prompt_variable(step_id=step_id, variable=variable) + if value is not None: + values_by_name[variable["name"]] = value + return values_by_name + + async def _prompt_variable( + self, *, step_id: str, variable: PromptVariableRequest + ) -> InputVariableValue | None: + name = variable["name"] + var_type = variable["type"] + required = variable["required"] + has_initial_value = "initial_value" in variable + initial_value = variable.get("initial_value") + enum_values = variable.get("enum_values") + + while True: + base_prompt = f"[{step_id}] Enter value for '{name}' ({var_type})" + if enum_values: + base_prompt += f" from {enum_values}" + if has_initial_value: + base_prompt += f" [default={json.dumps(initial_value)}]" + base_prompt += " (or 'cancel' to abort): " + + raw_value = (await self._read_line(base_prompt)).strip() + if raw_value.lower() == "cancel": + raise UserAbortedError( + f"User input was cancelled at variable '{name}'." + ) + + if raw_value == "": + if has_initial_value: + return initial_value + if required: + continue + return None + + parsed = self._parse_input_value( + raw_value=raw_value, + var_type=var_type, + enum_values=enum_values, + ) + if parsed is not None: + return parsed + + def _parse_input_value( + self, + *, + raw_value: str, + var_type: PromptVariableType, + enum_values: list[str] | None, + ) -> InputVariableValue | None: + if var_type == "string": + return raw_value + if var_type == "enum": + if enum_values is None: + return None + return raw_value if raw_value in enum_values else None + + if var_type in {"number", "boolean", "array", "object", "dataTable"}: + try: + parsed = json.loads(raw_value) + except json.JSONDecodeError: + return None + return self._validate_json_value(var_type=var_type, parsed=parsed) + + return None + + def _validate_json_value( + self, *, var_type: PromptVariableType, parsed: object + ) -> InputVariableValue | None: + if var_type == "number": + return parsed if isinstance(parsed, (int, float)) else None + if var_type == "boolean": + return parsed if isinstance(parsed, bool) else None + if var_type == "array": + return parsed if isinstance(parsed, list) else None + if var_type in {"object", "dataTable"}: + return parsed if isinstance(parsed, (dict, list)) else None + return None + + async def _read_line(self, prompt: str) -> str: + try: + return await asyncio.to_thread(self._reader, prompt) + except (EOFError, KeyboardInterrupt) as error: + raise UserAbortedError("User input was cancelled.") from error + + +__all__ = [ + "CliHumanInteractionHandler", + "HumanInteractionHandler", + "PromptVariableRequest", + "UserAbortedError", +] diff --git a/packages/narada/src/narada/types.py b/packages/narada/src/narada/types.py new file mode 100644 index 0000000..a658f7a --- /dev/null +++ b/packages/narada/src/narada/types.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from narada_core.types import ( + DispatchInputVariables, + DispatchInputVariableValue, + InputVariables, + InputVariableValue, + JsonPrimitive, + PromptVariableType, +) + +__all__ = [ + "DispatchInputVariableValue", + "DispatchInputVariables", + "InputVariableValue", + "InputVariables", + "JsonPrimitive", + "PromptVariableType", +] diff --git a/packages/narada/src/narada/window.py b/packages/narada/src/narada/window.py index 2b61390..e15cf3f 100644 --- a/packages/narada/src/narada/window.py +++ b/packages/narada/src/narada/window.py @@ -1,5 +1,6 @@ import asyncio import logging +import mimetypes import os import time from abc import ABC @@ -7,7 +8,7 @@ from http import HTTPStatus from io import IOBase from pathlib import Path -from typing import IO, Any, Mapping, TypeGuard, TypeVar, overload, override +from typing import IO, Any, Mapping, TypeGuard, TypeVar, cast, overload, override import aiohttp from narada_core.actions.models import ( @@ -51,12 +52,14 @@ Response, UserResourceCredentials, ) +from narada_core.types import DispatchInputVariableValue, InputVariables, JsonPrimitive from playwright.async_api import ( BrowserContext, ) from pydantic import BaseModel from narada.config import BrowserConfig +from narada.human_interaction import HumanInteractionHandler, PromptVariableRequest logger = logging.getLogger(__name__) @@ -69,18 +72,11 @@ class _InputVariableFileReference(BaseModel): key: str name: str + mimeType: str | None = None -type _JsonPrimitive = str | int | float | bool | None -type _InputVariableValue = ( - _JsonPrimitive - | IOBase - | list["_InputVariableValue"] - | dict[str, "_InputVariableValue"] -) -type _InputVariables = dict[str, _InputVariableValue] type _NormalizedInputVariableValue = ( - _JsonPrimitive + JsonPrimitive | _InputVariableFileReference | list["_NormalizedInputVariableValue"] | dict[str, "_NormalizedInputVariableValue"] @@ -106,6 +102,7 @@ class BaseBrowserWindow(ABC): _auth_headers: dict[str, str] _base_url: str _browser_window_id: str + _human_interaction_handler: HumanInteractionHandler | None def __init__( self, @@ -113,10 +110,12 @@ def __init__( auth_headers: dict[str, str], base_url: str, browser_window_id: str, + human_interaction_handler: HumanInteractionHandler | None = None, ) -> None: self._auth_headers = auth_headers self._base_url = base_url self._browser_window_id = browser_window_id + self._human_interaction_handler = human_interaction_handler @property def browser_window_id(self) -> str: @@ -161,7 +160,7 @@ async def _upload_file_impl(self, *, file: IO[Any]) -> File: return File(key=object_key) async def _normalize_input_variables( - self, *, input_variables: Mapping[str, Any] + self, *, input_variables: Mapping[str, DispatchInputVariableValue] ) -> _NormalizedInputVariables: normalized: _NormalizedInputVariables = {} for key, value in input_variables.items(): @@ -171,7 +170,7 @@ async def _normalize_input_variables( return normalized async def _normalize_input_variables_value_impl( - self, *, input_variable_value: Any + self, *, input_variable_value: DispatchInputVariableValue ) -> _NormalizedInputVariableValue: if isinstance(input_variable_value, list): return [ @@ -194,7 +193,7 @@ async def _normalize_input_variables_value_impl( ) return normalized - return input_variable_value + return cast(_NormalizedInputVariableValue, input_variable_value) @staticmethod def _is_uploadable_file(value: Any) -> TypeGuard[IO[Any]]: @@ -205,8 +204,11 @@ async def _upload_input_variable_file( self, *, input_variable_value: IO[Any] ) -> _InputVariableFileReference: filename = Path(input_variable_value.name).name + mime_type = mimetypes.guess_type(filename)[0] uploaded_file = await self._upload_file_impl(file=input_variable_value) - return _InputVariableFileReference(key=uploaded_file["key"], name=filename) + return _InputVariableFileReference( + key=uploaded_file["key"], name=filename, mimeType=mime_type + ) @overload async def dispatch_request( @@ -225,7 +227,7 @@ async def dispatch_request( user_resource_credentials: UserResourceCredentials | None = None, mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, - input_variables: Mapping[str, Any] | None = None, + input_variables: Mapping[str, DispatchInputVariableValue] | None = None, callback_url: str | None = None, callback_secret: str | None = None, callback_headers: Mapping[str, Any] | None = None, @@ -249,7 +251,7 @@ async def dispatch_request( user_resource_credentials: UserResourceCredentials | None = None, mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, - input_variables: Mapping[str, Any] | None = None, + input_variables: Mapping[str, DispatchInputVariableValue] | None = None, callback_url: str | None = None, callback_secret: str | None = None, callback_headers: Mapping[str, Any] | None = None, @@ -272,7 +274,7 @@ async def dispatch_request( user_resource_credentials: UserResourceCredentials | None = None, mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, - input_variables: Mapping[str, Any] | None = None, + input_variables: Mapping[str, DispatchInputVariableValue] | None = None, callback_url: str | None = None, callback_secret: str | None = None, callback_headers: Mapping[str, Any] | None = None, @@ -390,7 +392,7 @@ async def agent( time_zone: str = "America/Los_Angeles", mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, - input_variables: Mapping[str, Any] | None = None, + input_variables: Mapping[str, DispatchInputVariableValue] | None = None, timeout: int = 1000, ) -> AgentResponse[dict[str, Any]]: ... @@ -407,7 +409,7 @@ async def agent( time_zone: str = "America/Los_Angeles", mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, - input_variables: Mapping[str, Any] | None = None, + input_variables: Mapping[str, DispatchInputVariableValue] | None = None, timeout: int = 1000, ) -> AgentResponse[_StructuredOutput]: ... @@ -423,7 +425,7 @@ async def agent( time_zone: str = "America/Los_Angeles", mcp_servers: list[McpServer] | None = None, secret_variables: dict[str, str] | None = None, - input_variables: Mapping[str, Any] | None = None, + input_variables: Mapping[str, DispatchInputVariableValue] | None = None, timeout: int = 1000, ) -> AgentResponse: """Invokes an agent in the Narada extension side panel chat.""" @@ -540,6 +542,44 @@ async def print_message(self, *, message: str, timeout: int | None = None) -> No PrintMessageRequest(message=message), timeout=timeout ) + async def request_user_approval( + self, + *, + step_id: str, + prompt_message: str, + approve_label: str, + reject_label: str, + ) -> bool: + """Requests user approval via the configured human interaction handler.""" + if self._human_interaction_handler is None: + raise NotImplementedError( + "No HumanInteractionHandler configured. Pass " + "`human_interaction_handler=...` to Narada(...) when creating a browser window." + ) + return await self._human_interaction_handler.request_user_approval( + step_id=step_id, + prompt_message=prompt_message, + approve_label=approve_label, + reject_label=reject_label, + ) + + async def prompt_for_user_input( + self, + *, + step_id: str, + variables: list[PromptVariableRequest], + ) -> InputVariables: + """Prompts for user input via the configured human interaction handler.""" + if self._human_interaction_handler is None: + raise NotImplementedError( + "No HumanInteractionHandler configured. Pass " + "`human_interaction_handler=...` to Narada(...) when creating a browser window." + ) + return await self._human_interaction_handler.prompt_for_user_input( + step_id=step_id, + variables=variables, + ) + async def read_google_sheet( self, *, @@ -666,12 +706,14 @@ def __init__( browser_window_id: str, config: BrowserConfig, context: BrowserContext, + human_interaction_handler: HumanInteractionHandler | None = None, ) -> None: base_url = os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2") super().__init__( auth_headers=auth_headers, base_url=base_url, browser_window_id=browser_window_id, + human_interaction_handler=human_interaction_handler, ) self._browser_process_id = browser_process_id self._config = config @@ -708,6 +750,7 @@ def __init__( cloud_browser_session_id: str | None = None, api_key: str | None = None, auth_headers: dict[str, str] | None = None, + human_interaction_handler: HumanInteractionHandler | None = None, ) -> None: base_url = os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2") if auth_headers is None: @@ -717,6 +760,7 @@ def __init__( auth_headers=auth_headers, base_url=base_url, browser_window_id=browser_window_id, + human_interaction_handler=human_interaction_handler, ) self._cloud_browser_session_id = cloud_browser_session_id @@ -771,6 +815,7 @@ def __init__( session_id: str, api_key: str | None = None, auth_headers: dict[str, str] | None = None, + human_interaction_handler: HumanInteractionHandler | None = None, ) -> None: base_url = os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2") if auth_headers is None: @@ -780,6 +825,7 @@ def __init__( auth_headers=auth_headers, base_url=base_url, browser_window_id=browser_window_id, + human_interaction_handler=human_interaction_handler, ) self._session_id = session_id