diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 8e9392cc5..68ed92dfd 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -306,6 +306,17 @@ const createAppRouter = () => } } }, + { + path: 'extension-auth', + async lazy() { + const { Component } = await import( + '@/app/routes/extension-auth' + ) + return { + Component: () => + } + } + }, { path: ':sessionId', async lazy() { diff --git a/frontend/src/app/routes/extension-auth.tsx b/frontend/src/app/routes/extension-auth.tsx new file mode 100644 index 000000000..89f92f3e6 --- /dev/null +++ b/frontend/src/app/routes/extension-auth.tsx @@ -0,0 +1,220 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useSearchParams } from 'react-router' + +import { ACCESS_TOKEN } from '@/constants/auth' +import { Button } from '@/components/ui/button' +import { useAuth } from '@/contexts/auth-context' + +type Status = 'loading' | 'needs-consent' | 'sending' | 'done' | 'error' + +const EXTENSION_MESSAGE_TYPE = 'ii-extension-auth' + +export function ExtensionAuthPage() { + const [searchParams] = useSearchParams() + const { user, isAuthenticated, isLoading } = useAuth() + const [status, setStatus] = useState('loading') + const [error, setError] = useState(null) + + const extId = searchParams.get('ext_id') || '' + const nonce = searchParams.get('nonce') || '' + const browserVendor = searchParams.get('browser') || '' + + // The extension sends its own origin so the frontend can postMessage back + // specifically to the extension context listening in a content script. + // We keep it opaque (the extension is responsible for scoping). + const returnUrl = useMemo(() => { + // Extensions in Chromium use chrome-extension:///... in `window.location.href` + // but the extension's content script injected into THIS page will listen + // on window.postMessage, so we always postMessage to our own origin. + return window.location.origin + }, []) + + const postTokenToExtension = useCallback( + (token: string) => { + // postMessage bounces through a content script the extension injects + // into this frontend origin. Both the content script and this page + // are same-origin, so `window.postMessage` with targetOrigin=own + // origin is the safe path. The content script verifies the nonce + // matches what the extension sent, then relays to the background. + window.postMessage( + { + type: EXTENSION_MESSAGE_TYPE, + nonce, + ext_id: extId, + payload: { + access_token: token, + token_type: 'bearer', + }, + }, + returnUrl, + ) + }, + [nonce, extId, returnUrl], + ) + + // Validate required params. + useEffect(() => { + if (!extId || !nonce) { + setError( + 'Missing extension parameters. Please start the sign-in again from the extension.', + ) + setStatus('error') + } + }, [extId, nonce]) + + // Redirect to login if not authenticated. + useEffect(() => { + if (status === 'error') return + if (isLoading) return + if (isAuthenticated) { + setStatus('needs-consent') + return + } + + const here = `${window.location.pathname}${window.location.search}` + const loginUrl = `/login?return_to=${encodeURIComponent(here)}` + window.location.replace(loginUrl) + }, [isLoading, isAuthenticated, status]) + + const handleAllow = useCallback(() => { + const token = localStorage.getItem(ACCESS_TOKEN) + if (!token) { + setError('No access token found in this browser. Please sign in again.') + setStatus('error') + return + } + + setStatus('sending') + postTokenToExtension(token) + + // Poll for the extension's ack so the user sees "Done" after the + // content script confirms delivery. If no ack in 2s, still show done β + // the extension content script is responsible for closing this tab. + const timer = window.setTimeout(() => setStatus('done'), 1500) + const onAck = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return + const data = event.data as { type?: string; nonce?: string } + if (data?.type !== 'ii-extension-auth-ack' || data?.nonce !== nonce) return + window.clearTimeout(timer) + window.removeEventListener('message', onAck) + setStatus('done') + } + window.addEventListener('message', onAck) + }, [postTokenToExtension, nonce]) + + const handleDeny = useCallback(() => { + window.postMessage( + { type: `${EXTENSION_MESSAGE_TYPE}-denied`, nonce, ext_id: extId }, + returnUrl, + ) + window.setTimeout(() => window.close(), 300) + }, [nonce, extId, returnUrl]) + + if (status === 'loading' || isLoading) { + return + } + + if (status === 'error') { + return ( + + Sign-in error + {error} + + ) + } + + if (status === 'done') { + return ( + + You can close this tab + + The II-Agent extension is now signed in. + + + ) + } + + const displayName = user?.first_name + ? `${user.first_name} ${user.last_name ?? ''}`.trim() + : user?.email || 'your account' + + return ( + + + + + Β·Β·Β· + + π§© + + + + + Allow the II-Agent {browserVendor ? browserVendor : 'browser'} extension to sign in? + + + + Signed in as {displayName} + + + + + + + + + + + Deny + + + {status === 'sending' ? 'Sendingβ¦' : 'Allow'} + + + + + The extension ID requesting access is{' '} + {extId}. + + + + ) +} + +function CenterBox({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function Spinner() { + return ( + + ) +} + +function Line({ text }: { text: string }) { + return ( + + β + {text} + + ) +} + +export const Component = ExtensionAuthPage diff --git a/frontend/src/app/routes/login.tsx b/frontend/src/app/routes/login.tsx index 8b278afef..2379bb673 100644 --- a/frontend/src/app/routes/login.tsx +++ b/frontend/src/app/routes/login.tsx @@ -1,6 +1,6 @@ import { useGoogleLogin } from '@react-oauth/google' import { useCallback, useEffect, useMemo, useRef } from 'react' -import { Link, useNavigate } from 'react-router' +import { Link, useNavigate, useSearchParams } from 'react-router' import { useForm } from 'react-hook-form' import { z } from 'zod' import { zodResolver } from '@hookform/resolvers/zod' @@ -30,10 +30,27 @@ type IiAuthPayload = { export function LoginPage() { const { t } = useTranslation() const navigate = useNavigate() + const [searchParams] = useSearchParams() const { loginWithAuthCode } = useAuth() const dispatch = useAppDispatch() const isSage = useIsSageTheme() + // `return_to` is a relative path on this origin we should send the user + // back to after a successful login. Used by /extension-auth and + // /oauth-consent so protected flows survive a login bounce. + const safeReturnTo = useMemo(() => { + const raw = searchParams.get('return_to') + if (!raw) return '/' + try { + // Block absolute URLs / protocol-relative paths β only allow + // in-app paths to avoid open-redirect abuse. + if (!raw.startsWith('/') || raw.startsWith('//')) return '/' + return raw + } catch { + return '/' + } + }, [searchParams]) + const FormSchema = useMemo( () => z.object({ @@ -64,7 +81,7 @@ export function LoginPage() { onSuccess: async (codeResponse) => { try { await loginWithAuthCode(codeResponse.code) - navigate('/') + navigate(safeReturnTo) } catch (error: unknown) { const apiError = error as { response: { data: { detail: string } } @@ -121,7 +138,7 @@ export function LoginPage() { dispatch(fetchWishlist()) dispatch(fetchPins()) - navigate('/') + navigate(safeReturnTo) } catch (error) { console.error('Failed to finalize II login:', error) authHandledRef.current = false diff --git a/src/ii_agent/agents/types.py b/src/ii_agent/agents/types.py index 5b876addd..60b796fef 100644 --- a/src/ii_agent/agents/types.py +++ b/src/ii_agent/agents/types.py @@ -30,6 +30,7 @@ class AgentType(StrEnum): FAST_RESEARCH = "fast_research" RESEARCH_TO_WEBSITE = "research_to_website" MOBILE_APP = "mobile_app" + BROWSER_EXTENSION = "browser_extension" __all__ = ["AgentType"] diff --git a/src/ii_agent/clients/__init__.py b/src/ii_agent/clients/__init__.py new file mode 100644 index 000000000..9fab68b79 --- /dev/null +++ b/src/ii_agent/clients/__init__.py @@ -0,0 +1,7 @@ +"""Per-client agent factories and shared building blocks. + +Each subpackage under ``clients/`` (e.g. ``browser_extension``) wires the +standard ii-agent runtime to a specific external client. The modules +sitting directly under this package are generic helpers reused across all +clients β keep client-specific quirks inside the subpackage. +""" diff --git a/src/ii_agent/clients/browser_extension/__init__.py b/src/ii_agent/clients/browser_extension/__init__.py new file mode 100644 index 000000000..04f21c1b2 --- /dev/null +++ b/src/ii_agent/clients/browser_extension/__init__.py @@ -0,0 +1,22 @@ +"""Browser-extension client for the ii-browser Chrome extension. + +Sub-package of :mod:`ii_agent.clients` so it ships with the standard +distribution. The pieces this package owns are: + +* :mod:`ii_agent.clients.browser_extension.factory` β builds an + :class:`IIAgent` with extension-side overrides for system prompt and + tool/skill subset, delegating capability assembly to the shared + :mod:`ii_agent.clients.proxy_capabilities` helpers. + +The Pydantic content schemas (``BrowserExtensionCommandContent`` / +``BrowserExtensionContinueRunContent``) live in +:mod:`ii_agent.realtime.schemas` next to every other ``CommandContent`` +variant. The Socket.IO handlers live in +:mod:`ii_agent.realtime.handlers.browser_extension_query` and +:mod:`ii_agent.realtime.handlers.browser_extension_continue_run`, and are +registered by ``ii_agent.realtime.handlers.factory.CommandHandlerFactory``. +""" + +from ii_agent.clients.browser_extension.factory import browser_extension_agent_factory + +__all__ = ["browser_extension_agent_factory"] diff --git a/src/ii_agent/clients/browser_extension/config.py b/src/ii_agent/clients/browser_extension/config.py new file mode 100644 index 000000000..fde354436 --- /dev/null +++ b/src/ii_agent/clients/browser_extension/config.py @@ -0,0 +1,46 @@ +"""Browser-extension defaults. + +The wire payload's ``core_tools`` / ``core_skills`` / ``connector`` IS +the user's request and is taken as-is. These ``BROWSER_EXTENSION_*`` +sets are the **fallback defaults** the factory uses only when the +request leaves the matching key empty/null. They keep a sane baseline +for silent requests; populate them deliberately as needed. +""" + +from __future__ import annotations + +#: Fallback for ``requested_capabilities.core_tools`` (names from +#: :data:`TOOL_CLASS_MAP`) when the request doesn't specify any. +BROWSER_EXTENSION_DEFAULT_CORE_TOOLS: set[str] = set() + +#: Fallback for ``requested_capabilities.core_skills`` (names from the +#: user's persisted ``SkillTool`` registry) when the request doesn't +#: specify any. +BROWSER_EXTENSION_DEFAULT_CORE_SKILLS: set[str] = set() + +#: Fallback for ``requested_capabilities.connector`` (e.g. ``"github"``, +#: ``"google_drive"``) when the request doesn't specify one. The +#: connector still requires a wired ``connector_tool`` to instantiate. +BROWSER_EXTENSION_DEFAULT_CONNECTORS: set[str] = set() + +#: Fallback system prompt β used only when the request doesn't ship its own. +DEFAULT_SYSTEM_PROMPT = ( + "You are an in-browser AI assistant in the ii-browser Chrome extension. " + "Interact with the current page and selected text via available tools. " + "Call tools to read or act on page data; never guess missing information. " + "After each tool call, stop and wait for the result before continuing." +) + +#: Heading for the client-defined skill catalog appended to the system prompt. +CLIENT_SKILL_HEADING = "Skills available in the ii-browser extension runtime:" + +#: Tag used by the shared capability helpers when emitting warnings. +LOG_PREFIX = "browser_extension" + +# Recognised keys inside ``requested_capabilities.client_prompt`` for the +# browser-extension client. Kept here (and not in :mod:`proxy_capabilities`) +# because the proxy layer is intentionally agnostic about which prompt +# fragments any particular client ships β each client subpackage decides +# what it understands. +CLIENT_PROMPT_MODE_KEY = "mode" +CLIENT_PROMPT_HEADING = "Capability mode for this turn:" diff --git a/src/ii_agent/clients/browser_extension/factory.py b/src/ii_agent/clients/browser_extension/factory.py new file mode 100644 index 000000000..41b2aa275 --- /dev/null +++ b/src/ii_agent/clients/browser_extension/factory.py @@ -0,0 +1,232 @@ +"""Browser-extension specific agent factory. + +Builds an :class:`IIAgent` for one ii-browser request. Nothing from the +core ii-agent tool/skill catalog is loaded by default β the agent starts +empty and only gains the capabilities the request explicitly asks for via +``requested_capabilities``, gated by the allow-lists in +:mod:`~ii_agent.clients.browser_extension.config`. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from ii_agent.agents.agent import IIAgent +from ii_agent.agents.connector.base import BaseConnectorTool +from ii_agent.agents.factory.agent import AgentFactory, agent_factory as default_agent_factory +from ii_agent.agents.factory.tool_manager import AgentToolManager +from ii_agent.agents.models.utils import get_model +from ii_agent.agents.sessions.base import SessionStore +from ii_agent.agents.skills.base import SkillCreator +from ii_agent.agents.types import AgentType +from ii_agent.clients.browser_extension.config import ( + BROWSER_EXTENSION_DEFAULT_CONNECTORS, + BROWSER_EXTENSION_DEFAULT_CORE_SKILLS, + BROWSER_EXTENSION_DEFAULT_CORE_TOOLS, + CLIENT_SKILL_HEADING, + DEFAULT_SYSTEM_PROMPT, + LOG_PREFIX, + CLIENT_PROMPT_MODE_KEY, + CLIENT_PROMPT_HEADING, +) +from ii_agent.clients.proxy_capabilities import ( + RequestedCapabilities, + build_client_skill_prompt, + build_client_tools, + dedupe_tools, + include_connector_tools, + include_core_skills, + include_core_tools, +) +from ii_agent.core.config.llm_config import LLMConfig +from ii_agent.core.logger import logger +from ii_server.core.workspace import WorkspaceManager + + +class BrowserExtensionAgentFactory: + """Build a runtime-configured ``IIAgent`` for the ii-browser extension. + + Capability sources, in the order the factory composes them: + + 1. **Client-defined tools** (``requested_capabilities.client_tools``) β + external Functions whose execution happens entirely inside the + extension; ii-agent pauses on each call until the extension returns + ``external_tool_results``. + 2. **Client-defined skills** (``requested_capabilities.client_skills``) + β appended to the system prompt as an advisory catalog. + 3. **Core tools** (``requested_capabilities.core_tools``) β taken from + the request as-is. Falls back to + :data:`BROWSER_EXTENSION_DEFAULT_CORE_TOOLS` when the request is + silent. + 4. **Core skills** (``requested_capabilities.core_skills``) β taken + from the request as-is, pulled from the user's persisted + ``SkillTool`` registry. Falls back to + :data:`BROWSER_EXTENSION_DEFAULT_CORE_SKILLS` when silent. + 5. **Connector tools** (``requested_capabilities.connector``) β taken + from the request when a ``connector_tool`` is wired through. Falls + back to a value from :data:`BROWSER_EXTENSION_DEFAULT_CONNECTORS` + when silent. + """ + + def __init__(self, factory: AgentFactory): + self._factory = factory + + async def create_agent( + self, + user_id: str, + session_id: str, + llm_config: LLMConfig, + agent_type: AgentType = AgentType.BROWSER_EXTENSION, + workspace_manager: Optional[WorkspaceManager] = None, + session_store: Optional[SessionStore] = None, + tool_args: Optional[Dict[str, Any]] = None, + metadata: Optional[Dict[str, Any]] = None, + system_prompt: Optional[str] = None, + skill_creator: Optional[SkillCreator] = None, + connector_tool: Optional[BaseConnectorTool] = None, + requested_capabilities: Optional[Any] = None, + ) -> IIAgent: + logger.info( + "Creating browser_extension %s agent for session %s", + agent_type, + session_id, + ) + + capabilities = RequestedCapabilities.parse(requested_capabilities) + agent_tools: List[Any] = [] + + # Client-defined + client_tools = build_client_tools( + capabilities.client_tools, log_prefix=LOG_PREFIX + ) + if client_tools: + agent_tools.extend(client_tools) + logger.info( + "[browser_extension] Added %d client-defined tools", + len(client_tools), + ) + # Client skills β lazy-loaded. The system prompt gets a compact + # catalog (id + name + description + triggers); bodies are + # resolved on demand by the client-side `load_client_skill` + # tool (shipped via `requested_capabilities.client_tools`) so + # skill content never bloats every turn's context. + client_skill_prompt = build_client_skill_prompt( + capabilities.client_skills, + heading=CLIENT_SKILL_HEADING, + loader_tool_name="load_client_skill", + ) + + # Core tools β user's request wins; default kicks in if request is empty. + core_tools = include_core_tools( + capabilities.core_tools, + default_core_tools=BROWSER_EXTENSION_DEFAULT_CORE_TOOLS, + log_prefix=LOG_PREFIX, + ) + if core_tools: + agent_tools.extend(core_tools) + logger.info( + "[browser_extension] Added %d core tools", len(core_tools) + ) + + skill_tool, core_skill_prompt = await include_core_skills( + capabilities.core_skills, + skill_creator=skill_creator, + default_core_skills=BROWSER_EXTENSION_DEFAULT_CORE_SKILLS, + log_prefix=LOG_PREFIX, + ) + if skill_tool is not None: + agent_tools.append(skill_tool) + logger.info( + "[browser_extension] Added SkillTool with %d skills", + len(skill_tool._skills_registry), + ) + + # Connector β user's choice unless missing, then first default. + connector_tools = await include_connector_tools( + capabilities.connector, + connector_tool=connector_tool, + workspace_manager=workspace_manager, + default_connectors=BROWSER_EXTENSION_DEFAULT_CONNECTORS, + log_prefix=LOG_PREFIX, + ) + if connector_tools: + agent_tools.extend(connector_tools) + + # Final assembly + agent_tools = dedupe_tools(agent_tools) + AgentToolManager.log_tool_summary( + agent_tools, f"browser_extension agent {agent_type.value}" + ) + + model = get_model(llm_config.provider, llm_config=llm_config) + + if not system_prompt: + system_prompt = DEFAULT_SYSTEM_PROMPT + # Mode fragment goes first so the model reads "what can I do this + # turn" before the skill catalogs that depend on those capabilities. + # Unknown keys inside ``client_prompt`` are ignored (the proxy layer + # ships the dict through verbatim β see proxy_capabilities.py). + client_mode_prompt = _build_client_mode_prompt(capabilities.client_prompt) + if client_mode_prompt: + logger.info( + "[browser_extension] Folded client_prompt.%s into system prompt", + CLIENT_PROMPT_MODE_KEY, + ) + for prompt_section in ( + client_mode_prompt, + client_skill_prompt, + core_skill_prompt, + ): + if prompt_section: + system_prompt = f"{system_prompt}\n\n{prompt_section}" + + agent = IIAgent( + user_id=user_id, + session_id=session_id, + model=model, + name=f"{agent_type.value}_agent", + tools=agent_tools, + system_message=system_prompt, + session_store=session_store, + metadata=metadata, + sub_agents=[], + retries=0, + stream=True, + stream_events=True, + store_events=True, + ) + agent.set_id() + + logger.info( + "[browser_extension] Created %s agent with %d tools", + agent_type.value, + len(agent_tools), + ) + return agent + + +browser_extension_agent_factory = BrowserExtensionAgentFactory(default_agent_factory) + + +def _build_client_mode_prompt(client_prompt: dict[str, Any]) -> Optional[str]: + """Render the ``client_prompt.mode`` fragment for the system prompt. + + The ii-browser extension ships a short turn-scoped instruction here + so the LLM knows whether this turn has the full agent toolset or only + the read-only chat subset (and, in chat mode, that it should suggest + a mode switch when the user asks for an action that's been filtered + out of ``client_tools``). See ``services/chat/chatMode.ts`` in the + browser extension for the canonical wording. + + Returns ``None`` when no usable fragment is present so the caller can + skip the section without sprinkling ``if`` checks at every join site. + """ + if not client_prompt: + return None + mode = client_prompt.get(CLIENT_PROMPT_MODE_KEY) + if not isinstance(mode, str): + return None + mode = mode.strip() + if not mode: + return None + return f"{CLIENT_PROMPT_HEADING}\n\n{mode}" diff --git a/src/ii_agent/clients/proxy_capabilities.py b/src/ii_agent/clients/proxy_capabilities.py new file mode 100644 index 000000000..e38cd8f73 --- /dev/null +++ b/src/ii_agent/clients/proxy_capabilities.py @@ -0,0 +1,493 @@ +"""Proxy capabilities β what each client exposes to the agent loop. + +A "capability" is anything the LLM can invoke during an agent turn: +either a tool (callable with structured input) or a skill (advisory recipe +appended to the system prompt). Each request from a ``ii_agent.clients.*`` +subpackage carries a single ``requested_capabilities`` payload that may +mix four sources: + +* ``client_tools`` β JSON descriptors shipped over the wire by the client. + Become :class:`Function` stubs flagged + ``external_execution=True``: the agent loop pauses on + call and waits for the client to ship results back via + ``external_tool_results``. +* ``client_skills`` β JSON descriptors rendered into the system prompt as a + short advisory catalog. No Python execution. +* ``core_tools`` β names from ii-agent's :data:`TOOL_CLASS_MAP` that the + client wants to opt in to. Each client subpackage + decides which core tools it allows via an + ``allowed_core_tools`` whitelist. +* ``core_skills`` β names from the user's persisted ``SkillTool`` registry + to keep, gated by an ``allowed_core_skills`` whitelist. +* ``client_prompt`` β free-form dict of per-turn system-prompt fragments + the client wants folded into the agent's prompt. + This module stays agnostic about *which* keys the + dict carries β the consuming client subpackage decides + which keys it understands and how to render them. + Unknown keys are passed through unchanged so future + clients can add fragments without another rename. + +By default each client subpackage loads nothing from the core catalog β +it only exposes what the request explicitly asks for AND what the client +has whitelisted. + +Helpers are pure (no I/O, no DB) and parameterised by ``log_prefix`` / +``heading`` so multi-client deployments stay greppable. +""" + +from __future__ import annotations + +from typing import Any, Iterable, List, Optional + +from ii_agent.agents.factory.tool_manager import AgentToolManager +from ii_agent.agents.factory.tools import TOOL_CLASS_MAP +from ii_agent.agents.skills.prompt_db import generate_skill_tool_description +from ii_agent.agents.tools.function import Function +from ii_agent.core.logger import logger + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _normalize_name_set(values: Optional[Iterable[str]]) -> Optional[set[str]]: + if not values: + return None + normalized = { + value.strip().lower() + for value in values + if isinstance(value, str) and value.strip() + } + return normalized or None + + +def _normalize_name_list(values: Optional[Iterable[str]]) -> List[str]: + if not values: + return [] + out: List[str] = [] + seen: set[str] = set() + for value in values: + if not isinstance(value, str): + continue + cleaned = value.strip().lower() + if not cleaned or cleaned in seen: + continue + seen.add(cleaned) + out.append(cleaned) + return out + + +def _tool_name(tool: Any) -> str: + name = getattr(tool, "name", "") + return name.strip().lower() if isinstance(name, str) else "" + + +def _coerce_to_dict(value: Optional[Any]) -> Optional[dict[str, Any]]: + """Accept either a Pydantic model (``model_dump``) or a plain ``dict``.""" + if value is None: + return None + if hasattr(value, "model_dump"): + return value.model_dump() + if isinstance(value, dict): + return value + return None + + +def _pick_requested( + requested: Optional[Iterable[str]], + default: Optional[Iterable[str]], +) -> set[str]: + """Union of the client default and the user's request. + + Both inputs are normalized (lowercased, stripped, de-duped). For + example, ``default = {A, B}`` + ``requested = {A, C}`` β ``{A, B, C}``. + Returns an empty set when neither side specifies anything. + """ + return (_normalize_name_set(requested) or set()) | ( + _normalize_name_set(default) or set() + ) + + +# --------------------------------------------------------------------------- +# Requested capabilities (parsed view of the wire payload) +# --------------------------------------------------------------------------- + + +class RequestedCapabilities: + """Normalized view of a client's ``requested_capabilities`` payload. + + Attributes are always lists (possibly empty), so callers don't have to + juggle ``None`` checks at the use site. + """ + + __slots__ = ( + "client_tools", + "client_skills", + "client_prompt", + "core_tools", + "core_skills", + "connector", + "raw", + ) + + def __init__( + self, + *, + client_tools: List[dict[str, Any]], + client_skills: List[dict[str, Any]], + client_prompt: dict[str, Any], + core_tools: List[str], + core_skills: List[str], + connector: Optional[str], + raw: dict[str, Any], + ) -> None: + self.client_tools = client_tools + self.client_skills = client_skills + self.client_prompt = client_prompt + self.core_tools = core_tools + self.core_skills = core_skills + self.connector = connector + self.raw = raw + + @classmethod + def parse(cls, payload: Optional[Any]) -> "RequestedCapabilities": + as_dict = _coerce_to_dict(payload) or {} + + def _list_of_dicts(key: str) -> List[dict[str, Any]]: + value = as_dict.get(key) or [] + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict)] + + connector = as_dict.get("connector") + if not isinstance(connector, str) or not connector.strip(): + connector = None + else: + connector = connector.strip().lower() + + # ``client_prompt`` is a free-form per-turn prompt-fragment bag. + client_prompt_raw = as_dict.get("client_prompt") + client_prompt = ( + client_prompt_raw if isinstance(client_prompt_raw, dict) else {} + ) + + return cls( + client_tools=_list_of_dicts("client_tools"), + client_skills=_list_of_dicts("client_skills"), + client_prompt=client_prompt, + core_tools=_normalize_name_list(as_dict.get("core_tools")), + core_skills=_normalize_name_list(as_dict.get("core_skills")), + connector=connector, + raw=as_dict, + ) + + +# --------------------------------------------------------------------------- +# Core agent capabilities (per-client defaults unioned with the user request) +# --------------------------------------------------------------------------- + + +def include_core_tools( + requested: Optional[Iterable[str]], + *, + default_core_tools: Optional[Iterable[str]] = None, + log_prefix: str = "client", +) -> List[Any]: + """Instantiate core tools from the union of the user request and + ``default_core_tools``. + + No allow-list gating: both the request and the defaults are trusted. + Names not registered in :data:`TOOL_CLASS_MAP` are dropped with a + warning. + """ + selected = _pick_requested(requested, default_core_tools) + if not selected: + return [] + + instances: List[Any] = [] + for tool_name in sorted(selected): + target_name = next( + ( + registered + for registered in TOOL_CLASS_MAP + if registered.strip().lower() == tool_name + ), + None, + ) + if target_name is None: + logger.warning( + "[%s] Core tool '%s' is not registered", log_prefix, tool_name + ) + continue + instance = AgentToolManager.convert_tool(target_name) + if instance is None: + logger.warning( + "[%s] Failed to instantiate core tool '%s'", log_prefix, tool_name + ) + continue + instances.append(instance) + return instances + + +async def include_core_skills( + requested: Optional[Iterable[str]], + *, + skill_creator: Optional[Any], + default_core_skills: Optional[Iterable[str]] = None, + log_prefix: str = "client", +) -> tuple[Optional[Any], Optional[str]]: + """Build a ``SkillTool`` for skills from the union of the user request + and ``default_core_skills``. + + Returns ``(skill_tool, prompt_section)``. Either may be ``None`` β + callers should treat ``None`` as "drop the skill tool entirely / don't + append a prompt section". + """ + if skill_creator is None: + return None, None + + selected = _pick_requested(requested, default_core_skills) + if not selected: + return None, None + + skill_tool = await skill_creator.create_skill_tool() + if skill_tool is None: + return None, None + + registry = getattr(skill_tool, "_skills_registry", None) + if not isinstance(registry, dict): + return None, None + + filtered = { + name: skill + for name, skill in registry.items() + if name.strip().lower() in selected + } + missing = selected - {name.strip().lower() for name in registry.keys()} + for name in sorted(missing): + logger.warning( + "[%s] Core skill '%s' not found in user registry", log_prefix, name + ) + + if not filtered: + return None, None + + skill_tool._skills_registry = filtered + skill_tool.description = generate_skill_tool_description(list(filtered.values())) + return skill_tool, skill_tool.description + + +async def include_connector_tools( + requested: Optional[str], + *, + connector_tool: Optional[Any], + workspace_manager: Optional[Any] = None, + default_connectors: Optional[Iterable[str]] = None, + log_prefix: str = "client", +) -> List[Any]: + """Instantiate connector tools for the user-requested connector, + falling back to the first entry of ``default_connectors``. + + Connector is a single-slot capability (one connector per agent run), + so unlike tools/skills there's no union β the user's choice wins, and + we only consult ``default_connectors`` when the request is silent. + Returns ``[]`` when nothing is selected, no ``connector_tool`` is + wired, or instantiation fails. + """ + selected = (requested or "").strip().lower() or next( + iter(sorted(_normalize_name_set(default_connectors) or set())), None + ) + if not selected or connector_tool is None: + return [] + + try: + connector_tools = await connector_tool.create_connector_tools( + workspace_manager=workspace_manager, + ) + except Exception as exc: + logger.error( + "[%s] Failed to load connector '%s': %s", + log_prefix, + selected, + exc, + exc_info=True, + ) + return [] + + if connector_tools: + logger.info( + "[%s] Added %d connector tools (%s)", + log_prefix, + len(connector_tools), + selected, + ) + return connector_tools or [] + + +def dedupe_tools(agent_tools: List[Any]) -> List[Any]: + """Drop duplicate tools by case-insensitive name, preserving order.""" + seen: set[str] = set() + out: List[Any] = [] + for tool in agent_tools: + name = _tool_name(tool) + if not name: + out.append(tool) + continue + if name in seen: + continue + seen.add(name) + out.append(tool) + return out + + +# --------------------------------------------------------------------------- +# Client-defined capabilities +# --------------------------------------------------------------------------- + + +def _placeholder_tool_entrypoint(**_: Any) -> str: + """Body of every external-execution Function β never actually invoked.""" + return "This tool is executed by the client runtime." + + +def _build_external_function( + *, + name: str, + display_name: str, + description: str, + parameters: dict[str, Any], +) -> Function: + return Function( + name=name, + description=description, + parameters=parameters, + display_name=display_name, + entrypoint=_placeholder_tool_entrypoint, + skip_entrypoint_processing=True, + external_execution=True, + show_result=True, + ) + + +def build_client_tools( + client_tool_specs: Optional[Iterable[dict[str, Any]]], + *, + log_prefix: str = "client", +) -> List[Function]: + """Build external-execution Functions from ``client_tools`` descriptors. + + Each descriptor must carry ``name`` and ``description``; ``input_schema`` + falls back to an open object schema. ``aliases`` and ``display_name`` are + optional. + """ + if not client_tool_specs: + return [] + + client_tools: List[Function] = [] + for spec in client_tool_specs: + if not isinstance(spec, dict): + continue + name = spec.get("name") + description = spec.get("description") + if not isinstance(name, str) or not name.strip(): + continue + if not isinstance(description, str) or not description.strip(): + continue + + display_name = spec.get("display_name") + if not isinstance(display_name, str) or not display_name.strip(): + display_name = name + + parameters = spec.get("input_schema") + if not isinstance(parameters, dict) or not parameters: + parameters = {"type": "object", "properties": {}, "required": []} + + aliases = spec.get("aliases") or [] + if not isinstance(aliases, list): + aliases = [] + published_names = [name] + [ + a.strip() for a in aliases if isinstance(a, str) and a.strip() + ] + + seen: set[str] = set() + for published_name in published_names: + if published_name in seen: + continue + seen.add(published_name) + client_tools.append( + _build_external_function( + name=published_name, + display_name=display_name, + description=description, + parameters=parameters, + ) + ) + + if not client_tools and any( + isinstance(spec, dict) for spec in client_tool_specs + ): + logger.warning( + "[%s] client_tools provided but no valid descriptors were published", + log_prefix, + ) + return client_tools + + +def build_client_skill_prompt( + client_skill_specs: Optional[Iterable[dict[str, Any]]], + *, + heading: str = "Skills available in the client runtime:", + loader_tool_name: str = "load_client_skill", +) -> Optional[str]: + """Render client-authored skills as a lazy catalog in the system prompt. + + Skills follow a load-on-demand model: the catalog lists only ``name`` + and ``description`` (plus the stable ``id`` used as the loader key), + and the LLM calls ``loader_tool_name`` with that id to pull the body + when a skill matches the user's intent. Embedding every body up front + would burn context on recipes that don't fire, pressure the LLM to + follow every recipe, and scale badly as users author more skills. + """ + if not client_skill_specs: + return None + + entries: List[str] = [] + for skill in client_skill_specs: + if not isinstance(skill, dict): + continue + name = skill.get("name") + description = skill.get("description") + if not isinstance(name, str) or not name.strip(): + continue + if not isinstance(description, str) or not description.strip(): + continue + + # Prefer id as the loader key β names can collide on display but + # the wire payload treats id as stable. Fall back to name so a + # spec without an explicit id still resolves. + skill_id_raw = skill.get("id") + loader_key = ( + skill_id_raw.strip() + if isinstance(skill_id_raw, str) and skill_id_raw.strip() + else name.strip() + ) + + entries.append( + f"- {name.strip()} (id: {loader_key}) β {description.strip()}" + ) + + if not entries: + return None + + preamble = ( + f"{heading}\n" + f"Each item is a user-authored recipe shown as " + f"'name (id: ) β description'. When a user request matches a " + f"skill by name or description, call {loader_tool_name} with the " + f"matching id to load the full recipe, then follow it as your " + f"execution plan. Do not speculate about a skill's contents; if " + f"in doubt, load it. Skills are advisory: adapt the recipe to the " + f"user's actual request." + ) + return preamble + "\n\n" + "\n".join(entries) diff --git a/src/ii_agent/realtime/handlers/browser_extension_continue_run.py b/src/ii_agent/realtime/handlers/browser_extension_continue_run.py new file mode 100644 index 000000000..ec2eb2054 --- /dev/null +++ b/src/ii_agent/realtime/handlers/browser_extension_continue_run.py @@ -0,0 +1,235 @@ +"""Realtime ``browser_extension_continue_run`` handler. + +Resumes a paused browser-extension run with the same per-request overrides +captured at query time (system prompt, tool/skill subset). Also supports +forwarding ``external_tool_results`` β text-only tool execution outcomes +that the extension itself produced (e.g. DOM reads, page captures rendered +to text in the extension process). +""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from ii_agent.agents.sessions import AgentSessionStore +from ii_agent.agents.types import AgentType +from ii_agent.clients.browser_extension.factory import browser_extension_agent_factory +from ii_agent.core.db import get_db_session_local, get_session_factory +from ii_agent.core.logger import logger +from ii_agent.realtime.events.app_events import ( + AgentContinueEvent, + AgentProcessingEvent, + ErrorCode, +) +from ii_agent.realtime.handlers.base import CommandType +from ii_agent.realtime.handlers.continue_run import ContinueRunHandler +from ii_agent.realtime.schemas import BrowserExtensionContinueRunContent +from ii_agent.sessions.schemas import SessionInfo + + +class BrowserExtensionContinueRunHandler(ContinueRunHandler): + """Handle ``browser_extension_continue_run`` commands.""" + + _content_type = BrowserExtensionContinueRunContent + + def get_command_type(self) -> CommandType: + return CommandType.BROWSER_EXTENSION_CONTINUE_RUN + + async def handle( + self, + content: BrowserExtensionContinueRunContent, + session_info: SessionInfo, + ) -> None: + if session_info.api_version != "v1": + await self._send_error_event( + session_info.id, + error_code=ErrorCode.UNSUPPORTED_API_VERSION, + message="continue_run is only supported for v1 API version", + ) + return + + run_id = content.run_id + confirmed = content.confirmed + user_input = content.user_input + external_tool_results = content.external_tool_results or [] + + await self.send_event( + AgentContinueEvent( + session_id=UUID(str(session_info.id)), + content={ + "message": "Agent continuing...", + "confirmed": confirmed, + "run_id": run_id, + }, + ) + ) + + try: + session_store = AgentSessionStore(session_maker=get_session_factory()) + run_response = await session_store.get_by_run_id( + run_id=run_id, session_id=str(session_info.id) + ) + if not run_response: + await self._send_error_event( + session_info.id, + error_code=ErrorCode.RUN_NOT_FOUND, + message=f"Run {run_id} not found", + ) + return + + run_task_data = await self._load_run_task_data(run_id) + + for tool in run_response.tools_requiring_confirmation: + tool.confirmed = bool(confirmed) + logger.info( + "[browser_extension] continue confirmation run=%s tool_call_id=%s confirmed=%s", + run_id, + tool.tool_call_id, + confirmed, + ) + + for tool in run_response.tools_requiring_user_input: + if confirmed and user_input: + self._apply_user_input_to_tool(tool, user_input, run_id) + tool.answered = True + else: + tool.answered = False + + self._apply_external_tool_results( + run_response.tools, external_tool_results, run_id + ) + + llm_config = None + if session_info.model_setting_id: + try: + async with get_db_session_local() as db: + llm_config = ( + await self._container.model_setting_service.resolve_config_by_setting_id( + db, setting_id=session_info.model_setting_id + ) + ) + except Exception as exc: + logger.warning( + "[browser_extension] continue_run model resolution failed: %s", + exc, + ) + + agent = await browser_extension_agent_factory.create_agent( + user_id=str(session_info.user_id), + session_id=str(session_info.id), + llm_config=llm_config, + agent_type=AgentType(session_info.agent_type) + if session_info.agent_type + else AgentType.BROWSER_EXTENSION, + session_store=session_store, + metadata=run_task_data.get("metadata"), + system_prompt=run_task_data.get("system_prompt"), + requested_capabilities=run_task_data.get("requested_capabilities"), + skill_creator=self._create_skill_creator(session_info.user_id), + ) + + await self.send_event( + AgentProcessingEvent( + session_id=UUID(str(session_info.id)), + message="Resuming agent execution...", + content={ + "message": "Resuming agent execution...", + "run_id": run_id, + }, + ) + ) + + event_stream = agent.acontinue_run( + run_id=run_response.run_id, + updated_tools=run_response.tools, + stream=True, + stream_events=True, + ) + + await self.process_agent_event_stream( + event_stream, + session_info, + run_id=UUID(run_response.run_id), + is_user_key=llm_config.is_user_model() if llm_config else False, + llm_config=llm_config, + ) + + except ValueError as exc: + logger.error("[browser_extension] continue_run ValueError: %s", exc) + await self._send_error_event( + session_info.id, + error_code=ErrorCode.VALIDATION_ERROR, + message=str(exc), + ) + except Exception as exc: + logger.error( + "[browser_extension] continue_run failed: %s", exc, exc_info=True + ) + await self._send_error_event( + session_info.id, + error_code=ErrorCode.EXECUTION_ERROR, + message=f"Failed to continue run: {exc}", + ) + + async def _load_run_task_data(self, run_id: str) -> dict[str, Any]: + """Reload the original ``RunTask.data`` so overrides survive resume.""" + try: + run_task_id = UUID(str(run_id)) + except ValueError: + return {} + + async with get_db_session_local() as db: + run_task = await self._container.run_task_service.get_task_by_id( + db, task_id=run_task_id + ) + + if not run_task or not isinstance(run_task.data, dict): + return {} + return run_task.data + + @staticmethod + def _apply_external_tool_results( + tools: list[Any], + external_tool_results: list[dict[str, Any]], + run_id: str, + ) -> None: + """Inject extension-side tool execution results back into paused tools.""" + if not external_tool_results: + return + + by_id = { + str(item.get("tool_call_id")): item + for item in external_tool_results + if isinstance(item, dict) and item.get("tool_call_id") + } + + for tool in tools: + tool_call_id = getattr(tool, "tool_call_id", None) + if not tool_call_id: + continue + external = by_id.get(str(tool_call_id)) + if not external: + continue + + llm_content = external.get("llm_content") + user_display_content = external.get("user_display_content") + tool.result = ( + llm_content if llm_content is not None else user_display_content + ) + tool.tool_call_error = bool(external.get("is_error")) + + tool_input = external.get("tool_input") + if isinstance(tool_input, dict): + tool.tool_args = tool_input + + tool_name = external.get("tool_name") + if isinstance(tool_name, str) and tool_name.strip(): + tool.tool_name = tool_name + + logger.info( + "[browser_extension] applied external tool result run=%s tool_call_id=%s error=%s", + run_id, + tool_call_id, + tool.tool_call_error, + ) diff --git a/src/ii_agent/realtime/handlers/browser_extension_query.py b/src/ii_agent/realtime/handlers/browser_extension_query.py new file mode 100644 index 000000000..888c71eb3 --- /dev/null +++ b/src/ii_agent/realtime/handlers/browser_extension_query.py @@ -0,0 +1,181 @@ +"""Realtime ``browser_extension_query`` handler. + +Mirrors :class:`UserQueryHandler` but builds the agent through +:class:`BrowserExtensionAgentFactory` so requests can override the system +prompt, tool subset, and skill subset on a per-call basis. Sessions handled +here are stamped with ``AppKind.BROWSER_EXTENSION`` so the standard project +sidebar excludes them. +""" + +from __future__ import annotations + +from ii_agent.agents.sandboxes import upload_media_to_sandbox +from ii_agent.agents.sessions import AgentSessionStore +from ii_agent.agents.types import AgentType +from ii_agent.clients.browser_extension.factory import browser_extension_agent_factory +from ii_agent.core.db import get_db_session_local, get_session_factory +from ii_agent.core.logger import logger +from ii_agent.files.media import File as UrlFile, Image +from ii_agent.realtime.events.app_events import ErrorCode +from ii_agent.realtime.handlers.base import CommandType +from ii_agent.realtime.handlers.query import UserQueryHandler +from ii_agent.realtime.schemas import BrowserExtensionCommandContent +from ii_agent.sessions.schemas import SessionInfo +from ii_agent.sessions.types import AppKind +from ii_agent.settings.llm.schemas import ModelConfig +from ii_agent.tasks.types import RunStatus, TaskType + + +class BrowserExtensionQueryHandler(UserQueryHandler): + """Handle ``browser_extension_query`` commands from the ii-browser extension.""" + + _content_type = BrowserExtensionCommandContent + + def get_command_type(self) -> CommandType: + return CommandType.BROWSER_EXTENSION_QUERY + + async def handle( + self, + content: BrowserExtensionCommandContent, + existing_session: SessionInfo, + ) -> None: + await self._ensure_browser_extension_app_kind(existing_session.id) + + is_valid, session_info, llm_config = await self.validate_and_update_session( + existing_session, content + ) + if not is_valid or not session_info or not llm_config: + return + + await self._handle_browser_extension_query(content, session_info, llm_config) + + async def _handle_browser_extension_query( + self, + query_command: BrowserExtensionCommandContent, + session_info: SessionInfo, + llm_config: ModelConfig, + ) -> None: + run_service = self._container.run_task_service + file_service = self._container.file_service + + run_task = None + try: + async with get_db_session_local() as db: + run_task = await run_service.claim_task( + db, + session_id=session_info.id, + task_type=TaskType.AGENT_RUN, + data=query_command.model_dump(), + ) + user_event, _ = await self.create_user_message_event( + session_info, query_command, db, run_id=run_task.id + ) + await db.commit() + await self.send_event(user_event) + except Exception as exc: + logger.error( + "[browser_extension] Failed to claim task: %s", exc, exc_info=True + ) + await self._send_error_event( + session_id=session_info.id, + error_code=ErrorCode.INTERNAL_ERROR, + message=str(exc), + user_id=session_info.user_id, + ) + return + + try: + session_store = AgentSessionStore(session_maker=get_session_factory()) + agent = await browser_extension_agent_factory.create_agent( + user_id=str(session_info.user_id), + session_id=str(session_info.id), + llm_config=llm_config, + agent_type=AgentType(session_info.agent_type) + if session_info.agent_type + else AgentType.BROWSER_EXTENSION, + session_store=session_store, + tool_args=query_command.tool_args, + metadata=query_command.metadata, + system_prompt=query_command.system_prompt, + requested_capabilities=query_command.requested_capabilities, + skill_creator=self._create_skill_creator(session_info.user_id), + ) + + images: list[Image] = [] + files: list[UrlFile] = [] + if query_command.files: + async with get_db_session_local() as db: + images, files = await file_service.prepare_agent_files( + db, + file_ids=query_command.files, + user_id=session_info.user_id, + session_id=session_info.id, + ) + + if images or files: + sandbox_service = self._container.sandbox_service + async with get_db_session_local() as db: + sandbox = await sandbox_service.init_sandbox( + db, + session_id=session_info.id, + user_id=session_info.user_id, + ) + agent.sandbox = sandbox + await sandbox.create_directory(sandbox.upload_path, exist_ok=True) + sandbox_files, sandbox_images = await upload_media_to_sandbox( + sandbox=sandbox, + files=files or [], + images=images or [], + upload_path=sandbox.upload_path, + ) + if sandbox_files: + files = sandbox_files + if sandbox_images: + images = sandbox_images + + event_stream = await agent.arun( + query_command.text, + stream=True, + stream_events=True, + run_id=str(run_task.id), + images=images or None, + files=files or None, + yield_run_output=False, + ) + + await self.process_agent_event_stream( + event_stream, + session_info, + run_id=run_task.id, + is_user_key=llm_config.is_user_model(), + llm_config=llm_config, + ) + except Exception as exc: + logger.opt(exception=True).error( + "[browser_extension] Error processing query: %s", exc + ) + async with get_db_session_local() as db: + await run_service.transition_status( + db, task_id=run_task.id, to_status=RunStatus.FAILED + ) + await db.commit() + raise + + async def _ensure_browser_extension_app_kind(self, session_id) -> None: + """Stamp ``app_kind = browser_extension`` on the session if not set.""" + try: + async with get_db_session_local() as db: + session = await self._container.session_service._session_repo.get_by_id( + db, session_id + ) + if session is None: + return + if session.app_kind != AppKind.BROWSER_EXTENSION: + session.app_kind = AppKind.BROWSER_EXTENSION + await db.commit() + except Exception as exc: + logger.warning( + "[browser_extension] Failed to stamp app_kind on session %s: %s", + session_id, + exc, + ) diff --git a/src/ii_agent/realtime/handlers/factory.py b/src/ii_agent/realtime/handlers/factory.py index a27ceeccc..fb2b379a9 100644 --- a/src/ii_agent/realtime/handlers/factory.py +++ b/src/ii_agent/realtime/handlers/factory.py @@ -38,6 +38,12 @@ from ii_agent.realtime.handlers.design_save_state import DesignSaveStateHandler from ii_agent.realtime.handlers.design_sync_state import DesignSyncStateHandler from ii_agent.realtime.handlers.slide_deck_sync_state import SlideDeckSyncStateHandler +from ii_agent.realtime.handlers.browser_extension_query import ( + BrowserExtensionQueryHandler, +) +from ii_agent.realtime.handlers.browser_extension_continue_run import ( + BrowserExtensionContinueRunHandler, +) class CommandHandlerFactory: @@ -92,6 +98,12 @@ def _initialize_handlers(self) -> None: CommandType.DESIGN_SAVE_STATE: DesignSaveStateHandler(pubsub=ps, container=ct), CommandType.DESIGN_SYNC_STATE: DesignSyncStateHandler(pubsub=ps, container=ct), CommandType.SLIDE_DECK_SYNC_STATE: SlideDeckSyncStateHandler(pubsub=ps, container=ct), + CommandType.BROWSER_EXTENSION_QUERY: BrowserExtensionQueryHandler( + pubsub=ps, container=ct + ), + CommandType.BROWSER_EXTENSION_CONTINUE_RUN: BrowserExtensionContinueRunHandler( + pubsub=ps, container=ct + ), } def get_handler(self, command_type: CommandType) -> BaseCommandHandler | None: diff --git a/src/ii_agent/realtime/schemas.py b/src/ii_agent/realtime/schemas.py index e0a2a4b5a..00d06365c 100644 --- a/src/ii_agent/realtime/schemas.py +++ b/src/ii_agent/realtime/schemas.py @@ -58,6 +58,9 @@ class CommandType(StrEnum): APPLE_CHECK_AUTH = "apple_check_auth" SAVE_EXPO_TOKEN = "save_expo_token" + # Browser extension (ii-browser) + BROWSER_EXTENSION_QUERY = "browser_extension_query" + BROWSER_EXTENSION_CONTINUE_RUN = "browser_extension_continue_run" # --------------------------------------------------------------------------- # Base empty content (shared fields for no-payload commands) @@ -418,6 +421,33 @@ class SlideDeckSyncStateContent(BaseModel): model_config = ConfigDict(extra="allow") +# --------------------------------------------------------------------------- +# Browser-extension (ii-browser) content models +# --------------------------------------------------------------------------- + + +class BrowserExtensionCommandContent(BaseCommandQuery): + """Payload for ``browser_extension_query``.""" + + command: Literal[CommandType.BROWSER_EXTENSION_QUERY] = ( + CommandType.BROWSER_EXTENSION_QUERY + ) + system_prompt: str | None = None + requested_capabilities: dict[str, Any] | None = None + + +class BrowserExtensionContinueRunContent(ContinueRunContent): + """Payload for ``browser_extension_continue_run``.""" + + command: Literal[CommandType.BROWSER_EXTENSION_CONTINUE_RUN] = ( + CommandType.BROWSER_EXTENSION_CONTINUE_RUN + ) + run_id: str + confirmed: bool + user_input: dict[str, str] = {} + external_tool_results: list[dict[str, Any]] | None = None + + # --------------------------------------------------------------------------- # Discriminated union of all command content types # --------------------------------------------------------------------------- @@ -430,6 +460,8 @@ class SlideDeckSyncStateContent(BaseModel): EnhancePromptContent, StartForkContent, ContinueRunContent, + BrowserExtensionCommandContent, + BrowserExtensionContinueRunContent, PublishProjectContent, CloudRunPublishContent, SaveEnvContent, diff --git a/src/ii_agent/sessions/types.py b/src/ii_agent/sessions/types.py index dabd03280..cf2c97383 100644 --- a/src/ii_agent/sessions/types.py +++ b/src/ii_agent/sessions/types.py @@ -16,3 +16,4 @@ class AppKind(StrEnum): AGENT = "agent" CHAT = "chat" + BROWSER_EXTENSION = "browser_extension"
{error}
+ The II-Agent extension is now signed in. +
+ Signed in as {displayName} +
+ The extension ID requesting access is{' '} + {extId}. +
{extId}