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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/narada-core/src/narada_core/human_interaction.py
Original file line number Diff line number Diff line change
@@ -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: ...
12 changes: 12 additions & 0 deletions packages/narada-core/src/narada_core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -292,6 +302,8 @@ class ObjectSetPropertiesTrace(TypedDict):
| DataTableInsertRowTrace
| DataTableUpdateCellValueTrace
| ObjectSetPropertiesTrace
| UserApprovalTrace
| PromptForUserInputTrace
)


Expand Down
24 changes: 24 additions & 0 deletions packages/narada-core/src/narada_core/types.py
Original file line number Diff line number Diff line change
@@ -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",
]
14 changes: 9 additions & 5 deletions packages/narada-pyodide/src/narada/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -24,4 +27,5 @@
"render_html",
"Response",
"ResponseContent",
"UserAbortedError",
]
13 changes: 13 additions & 0 deletions packages/narada-pyodide/src/narada/human_interaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from __future__ import annotations

from narada_core.human_interaction import (
HumanInteractionHandler,
PromptVariableRequest,
UserAbortedError,
)

__all__ = [
"HumanInteractionHandler",
"PromptVariableRequest",
"UserAbortedError",
]
19 changes: 19 additions & 0 deletions packages/narada-pyodide/src/narada/types.py
Original file line number Diff line number Diff line change
@@ -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",
]
68 changes: 60 additions & 8 deletions packages/narada-pyodide/src/narada/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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]]: ...

Expand All @@ -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]: ...

Expand All @@ -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."""
Expand Down Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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}")
Expand All @@ -621,20 +665,28 @@ 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:
return f"LocalBrowserWindow(browser_window_id={self.browser_window_id})"


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:
Expand Down
8 changes: 8 additions & 0 deletions packages/narada/src/narada/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,8 +24,10 @@
"Agent",
"BrowserConfig",
"CloudBrowserWindow",
"CliHumanInteractionHandler",
"download_file",
"File",
"HumanInteractionHandler",
"LocalBrowserWindow",
"Narada",
"NaradaError",
Expand All @@ -34,4 +41,5 @@
"render_html",
"Response",
"ResponseContent",
"UserAbortedError",
]
Loading
Loading