diff --git a/.gitignore b/.gitignore index 7f47d45b..08cc6e1d 100644 --- a/.gitignore +++ b/.gitignore @@ -82,12 +82,22 @@ venv.bak/ # Local models and benchmark artifacts (never publish) /artifacts/ +/output/ *.bin *.safetensors *.gguf *.ggml *.moe +# Runtime databases and secret material (never commit or publish) +*.sqlite +*.sqlite3 +*.sqlite3-shm +*.sqlite3-wal +vault.key +vault.aesgcm +*.private.pem + # Logs *.log logs/ @@ -113,7 +123,7 @@ omlx/_engine_commits.json omlx/_build_info.py # Tailwind CSS standalone CLI binary (download on demand via build_css.py) -omlx/admin/tailwindcss-* +ai2apps/web/tailwindcss-* # Git worktrees .worktrees/ diff --git a/MANIFEST.in b/MANIFEST.in index 281347a5..2b144437 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,4 +3,12 @@ global-exclude *.so global-exclude *.dylib global-exclude *.metallib +global-exclude *.private.pem +global-exclude vault.key +global-exclude vault.aesgcm +global-exclude *.sqlite +global-exclude *.sqlite3 +global-exclude *.sqlite3-shm +global-exclude *.sqlite3-wal prune artifacts +prune output diff --git a/ai2apps/_version.py b/ai2apps/_version.py index 341195d4..2e379484 100644 --- a/ai2apps/_version.py +++ b/ai2apps/_version.py @@ -1,3 +1,3 @@ """AI2Apps product version, independent from the embedded oMLX runtime.""" -__version__ = "0.1.0.dev1" +__version__ = "0.1.0.dev2" diff --git a/ai2apps/agents/__init__.py b/ai2apps/agents/__init__.py new file mode 100644 index 00000000..6be38885 --- /dev/null +++ b/ai2apps/agents/__init__.py @@ -0,0 +1,58 @@ +"""Asynchronous Agent Runtime public contracts.""" + +from .delegation import install_delegation_service +from .general import GeneralAgentExecutor, install_general_agent +from .models import ( + AgentAction, + AgentDefinitionRecord, + AgentDefinitionStatus, + AgentExecutionContext, + AgentRunRecord, + AgentRunStatus, + AgentRuntimeError, + CompleteAction, + ContinueAction, + FailAction, + InteractionAction, + InteractionKind, + InteractionRecord, + InteractionStatus, + ModelCallAction, + RunStepRecord, + RunStepStatus, + StatusAction, + StatusLineRecord, + ToolCallAction, +) +from .repository import AgentRepository +from .runtime import AgentRuntime, diagnostic_executor, install_diagnostic_agent + +__all__ = [ + "AgentAction", + "AgentDefinitionRecord", + "AgentDefinitionStatus", + "AgentExecutionContext", + "AgentRepository", + "AgentRunRecord", + "AgentRunStatus", + "AgentRuntime", + "AgentRuntimeError", + "CompleteAction", + "ContinueAction", + "FailAction", + "GeneralAgentExecutor", + "InteractionAction", + "InteractionKind", + "InteractionRecord", + "InteractionStatus", + "ModelCallAction", + "RunStepRecord", + "RunStepStatus", + "StatusAction", + "StatusLineRecord", + "ToolCallAction", + "diagnostic_executor", + "install_diagnostic_agent", + "install_general_agent", + "install_delegation_service", +] diff --git a/ai2apps/agents/delegation.py b/ai2apps/agents/delegation.py new file mode 100644 index 00000000..c8530327 --- /dev/null +++ b/ai2apps/agents/delegation.py @@ -0,0 +1,164 @@ +"""Built-in Agent-to-Agent delegation Service and Tool.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from ai2apps.services import ( + ServiceInstanceStatus, + ServiceRegistry, + ServiceRepository, + ServiceRuntimeMode, + ToolCallContext, + ToolProviderError, +) + +from .models import AgentRunStatus +from .repository import AgentRepository +from .runtime import AgentRuntime + + +def install_delegation_service( + agents: AgentRepository, + runtime: AgentRuntime, + services: ServiceRepository, + registry: ServiceRegistry, +) -> None: + """Expose durable child AgentRuns as the ``agent.delegate`` Tool.""" + + service = services.ensure_service( + service_key="ai2apps.agent-runtime", + package_id="ai2apps.agent-runtime", + package_version="1.0.0", + display_name="Agent Runtime", + runtime_mode=ServiceRuntimeMode.IN_PROCESS, + capabilities=("agent.delegation",), + ) + instance = services.ensure_instance( + service_id=service.id, + provider_key="builtin:agent-runtime", + status=ServiceInstanceStatus.RUNNING, + endpoint="/v1/platform/tools/agent.delegate/invoke", + health={"status": "ok", "max_depth": agents.MAX_DELEGATION_DEPTH}, + ) + services.ensure_tool( + service_id=service.id, + qualified_name="agent.delegate", + display_name="Delegate to Agent", + description=( + "Run a bounded child Agent in the current Session and return its " + "durable result to the parent Agent." + ), + input_schema={ + "type": "object", + "properties": { + "agent": {"type": "string", "minLength": 1}, + "task": {"type": "string", "minLength": 1, "maxLength": 32768}, + "request_key": { + "type": "string", + "minLength": 1, + "maxLength": 128, + }, + "parameters": {"type": "object"}, + "context": {"type": "string", "maxLength": 16384}, + "budget": { + "type": "object", + "properties": { + "max_steps": {"type": "integer", "minimum": 1, "maximum": 24}, + "max_model_tokens": { + "type": "integer", + "minimum": 1, + "maximum": 100000, + }, + "timeout_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 900, + }, + }, + "additionalProperties": False, + }, + }, + "required": ["agent", "task", "request_key"], + "additionalProperties": False, + }, + output_schema={"type": "object"}, + effects=(), + timeout_ms=910_000, + ) + + async def delegate( + arguments: dict[str, Any], context: ToolCallContext + ) -> dict[str, Any]: + if context.trace_id is None or context.session_id is None: + raise ToolProviderError("Delegation requires an Agent Run and Session") + parent = agents.get_run(context.trace_id) + if parent.session_id != context.session_id: + raise ToolProviderError("Delegation Session does not match parent Run") + request_key = arguments["request_key"] + child = agents.get_delegated_child(parent.id, request_key) + if child is None: + budget = dict(arguments.get("budget") or {}) + delegation_context = { + "instructions": arguments.get("context", ""), + "parent_message_id": parent.input.get("message_id"), + } + child_input = { + "model": parent.input.get("model", ""), + "prompt": arguments["task"], + "parameters": dict(arguments.get("parameters") or {}), + "instructions": arguments.get("context", ""), + "invocation": {"source": "delegation"}, + } + child, _ = agents.create_run( + session_id=parent.session_id, + agent_key=arguments["agent"], + input=child_input, + idempotency_key=f"delegate:{parent.id}:{request_key}", + priority=parent.priority, + trace_id=parent.id, + parent_run_id=parent.id, + delegation={ + "request_key": request_key, + "task": arguments["task"], + "parameters": dict(arguments.get("parameters") or {}), + "context": delegation_context, + "budget": budget, + }, + ) + runtime.wake() + definition = agents.get_definition(child.agent_definition_id) + await context.report_progress( + f"Waiting for {definition.display_name}", + phase="waiting_subruns", + content={ + "child_run_id": child.id, + "agent_key": definition.agent_key, + "depth": child.depth, + }, + ) + remaining = max( + 0.1, (child.deadline_at - datetime.now(UTC)).total_seconds() + 1 + ) + child = await runtime.wait_for_terminal(child.id, timeout=remaining) + await context.report_progress( + f"{definition.display_name} {child.status.value}", + phase="subrun_completed", + content={"child_run_id": child.id, "status": child.status.value}, + ) + if child.status is not AgentRunStatus.COMPLETED: + raise ToolProviderError( + f"Delegated AgentRun {child.id} ended as {child.status.value}: " + f"{(child.error or {}).get('message', '')}" + ) + return { + "child_run_id": child.id, + "agent_key": definition.agent_key, + "status": child.status.value, + "output": child.output or {}, + } + + registry.bind_tool( + "agent.delegate", provider_key=instance.provider_key, handler=delegate + ) diff --git a/ai2apps/agents/general.py b/ai2apps/agents/general.py new file mode 100644 index 00000000..1e952ee3 --- /dev/null +++ b/ai2apps/agents/general.py @@ -0,0 +1,654 @@ +"""Built-in resumable model -> Tool -> model Agent executor.""" + +from __future__ import annotations + +import asyncio +import copy +import fnmatch +import hashlib +import json +import re +from typing import Any + +from ai2apps.core import MessageRole, MessageStatus, ResourceNotFoundError +from ai2apps.events import EventStore +from ai2apps.services import ToolCallContext, ToolDescriptorRecord, ToolGateway +from ai2apps.storage import MessagePartInput, PlatformDatabase +from ai2apps.storage.repositories import MessageRepository + +from .models import ( + AgentAction, + AgentExecutionContext, + CompleteAction, + FailAction, + ModelCallAction, + RunStepRecord, + RunStepStatus, + ToolCallAction, +) +from .repository import AgentRepository +from .runtime import AgentRuntime + +_SAFE_TOOL_NAME = re.compile(r"[^A-Za-z0-9_-]") +_MODEL_OPTION_KEYS = frozenset( + { + "temperature", + "top_p", + "max_tokens", + "max_completion_tokens", + "reasoning_effort", + "seed", + "stop", + } +) + + +def _tool_alias(name: str) -> str: + alias = _SAFE_TOOL_NAME.sub("__", name).strip("_") or "tool" + if alias != name or len(alias) > 64: + digest = hashlib.sha256(name.encode()).hexdigest()[:10] + alias = f"{alias[:53]}_{digest}" + return alias + + +def _provider_tool_schema(schema: dict[str, Any]) -> dict[str, Any]: + """Return an OpenAI-compatible function schema without weakening execution validation. + + Providers reject composition keywords at the function parameters root even + when the schema is otherwise a valid object schema. The Tool gateway still + validates calls against the original schema before execution. + """ + + normalized = copy.deepcopy(schema) + normalized["type"] = "object" + for keyword in ("oneOf", "anyOf", "allOf", "enum", "const", "not"): + normalized.pop(keyword, None) + return normalized + + +def _tool_catalog(tools: tuple[ToolDescriptorRecord, ...]): + aliases: dict[str, ToolDescriptorRecord] = {} + definitions = [] + for tool in sorted(tools, key=lambda item: item.qualified_name): + alias = _tool_alias(tool.qualified_name) + owner = aliases.get(alias) + if owner is not None and owner.qualified_name != tool.qualified_name: + digest = hashlib.sha256(tool.qualified_name.encode()).hexdigest()[:10] + alias = f"{alias[:53]}_{digest}" + aliases[alias] = tool + definitions.append( + { + "type": "function", + "function": { + "name": alias, + "description": tool.description or tool.display_name, + "parameters": _provider_tool_schema(tool.input_schema), + }, + } + ) + return aliases, definitions + + +def _part_content(kind: str, content: dict[str, Any]): + if kind == "text": + return content.get("text", "") + if kind in {"openai_content", "chat_ui_content"}: + return content.get("content") + return json.dumps(content, ensure_ascii=False, sort_keys=True) + + +def _normalize_attachment_references(value): + """Keep durable IDs in history without sending unresolved files to providers.""" + if not isinstance(value, list): + return value + normalized = [] + for part in value: + if not isinstance(part, dict) or part.get("type") != "file": + normalized.append(part) + continue + file_value = part.get("file") or {} + attachment_id = file_value.get("file_id") or file_value.get("attachment_id") + if not attachment_id: + normalized.append(part) + continue + filename = file_value.get("filename") or "document" + normalized.append( + { + "type": "text", + "text": ( + f"[Attached document: {filename}; attachment_id={attachment_id}. " + "Use attachment.status, document.read, or document.search to inspect it.]" + ), + } + ) + return normalized + + +def _session_message(record) -> dict[str, Any] | None: + if record.message.status is not MessageStatus.COMPLETED: + return None + role = record.message.role + if role is MessageRole.APP: + role = MessageRole.USER + contents = [_part_content(part.kind, part.content) for part in record.parts] + if len(contents) == 1: + content = _normalize_attachment_references(contents[0]) + else: + text_parts = [item for item in contents if isinstance(item, str)] + content = ( + "\n".join(text_parts) if len(text_parts) == len(contents) else _normalize_attachment_references(contents) + ) + return {"role": role.value, "content": content} + + +def _model_message(output: dict[str, Any]): + choices = output.get("choices") + if not isinstance(choices, list) or not choices: + return None, None, "Model response has no choices" + choice = choices[0] + if not isinstance(choice, dict) or not isinstance(choice.get("message"), dict): + return None, None, "Model response choice has no message" + message = choice["message"] + tool_calls = message.get("tool_calls") or [] + if not isinstance(tool_calls, list): + return None, None, "Model response tool_calls must be an array" + return message, choice.get("finish_reason"), None + + +def _tool_action_key(model_sequence: int, index: int, call_id: str) -> str: + digest = hashlib.sha256(call_id.encode()).hexdigest()[:12] + return f"tool:{model_sequence}:{index}:{digest}" + + +class GeneralAgentExecutor: + """Reconstruct execution from durable Messages and Steps on every turn.""" + + def __init__( + self, + database: PlatformDatabase, + events: EventStore, + tools: ToolGateway, + ) -> None: + self.messages = MessageRepository(database, events) + self.tools = tools + + def _messages_for_run(self, context: AgentExecutionContext): + records = self.messages.list_for_session(context.run.session_id, limit=1_000) + delegated = context.run.parent_run_id is not None + requested_message_id = context.run.input.get("message_id") + if requested_message_id is not None: + try: + requested = self.messages.get( + requested_message_id, + session_id=context.run.session_id, + ) + except ResourceNotFoundError: + return None, "input_message_not_found" + if requested.message.role is not MessageRole.USER: + return None, "input_message_must_be_user" + cutoff = requested.message.sequence + elif delegated: + parent_message_id = context.run.delegation.get("context", {}).get( + "parent_message_id" + ) + parent_message = next( + ( + item + for item in records + if item.message.id == parent_message_id + ), + None, + ) + cutoff = ( + parent_message.message.sequence + if parent_message is not None + else (records[-1].message.sequence if records else 0) + ) + else: + generated_input = next( + ( + item + for item in records + if item.message.metadata.get("agent_run_id") == context.run.id + and item.message.metadata.get("agent_input") + ), + None, + ) + if generated_input is None: + return None, "missing_agent_input" + cutoff = generated_input.message.sequence + messages = [] + instructions = context.definition.manifest.get("instructions") + if isinstance(instructions, str) and instructions: + messages.append({"role": "system", "content": instructions}) + run_instructions = context.run.input.get("instructions") + if isinstance(run_instructions, str) and run_instructions.strip(): + messages.append({"role": "system", "content": run_instructions}) + eligible = [] + for item in records: + if item.message.sequence > cutoff: + continue + if item.message.metadata.get( + "agent_run_id" + ) == context.run.id and not item.message.metadata.get("agent_input"): + continue + converted = _session_message(item) + if converted is not None: + eligible.append(converted) + configured_limit = context.definition.manifest.get("context_message_limit", 200) + message_limit = ( + configured_limit + if isinstance(configured_limit, int) and configured_limit > 0 + else 200 + ) + omitted = max(0, len(eligible) - message_limit) + if omitted: + messages.append( + { + "role": "system", + "content": ( + f"{omitted} earlier Session messages were omitted by the " + "Agent context limit." + ), + } + ) + messages.extend(eligible[-message_limit:]) + if delegated: + task = context.run.delegation.get("task") or context.run.input.get("prompt") + messages.append({"role": "user", "content": str(task)}) + return messages, None + + def _ensure_prompt(self, context: AgentExecutionContext): + prompt = context.run.input.get("prompt") + rich_content = context.run.input.get("content") + supplied = sum( + value is not None + for value in (context.run.input.get("message_id"), prompt, rich_content) + ) + if supplied > 1: + return "ambiguous_agent_input" + if context.run.input.get("message_id") is not None: + return None + if prompt is None and rich_content is None: + return "missing_agent_input" + if prompt is not None and (not isinstance(prompt, str) or not prompt.strip()): + return "prompt_must_be_non_empty_text" + if rich_content is not None and ( + not isinstance(rich_content, list) or not rich_content + ): + return "content_must_be_non_empty_array" + if context.run.parent_run_id is not None: + return None + part = ( + MessagePartInput(kind="text", content={"text": prompt}) + if prompt is not None + else MessagePartInput( + kind="openai_content", + content={"content": rich_content}, + ) + ) + self.messages.append( + session_id=context.run.session_id, + role=MessageRole.USER, + parts=(part,), + idempotency_key=f"agent-run:{context.run.id}:user", + metadata={"agent_run_id": context.run.id, "agent_input": True}, + trace_id=context.run.id, + ) + # The generated input is part of context, unlike a generated assistant + # result, so callers on the next pass address it explicitly. + return None + + def _available_tools(self, context: AgentExecutionContext): + candidates = self.tools.list_tools( + ToolCallContext( + caller_id=f"agent:{context.definition.agent_key}", + session_id=context.run.session_id, + granted_capabilities=frozenset(context.run.granted_capabilities), + trace_id=context.run.id, + ), + include_requiring_approval=True, + ) + patterns = context.definition.manifest.get("allowed_tools", ["*"]) + if not isinstance(patterns, list) or not all( + isinstance(item, str) for item in patterns + ): + return (), "invalid_agent_tool_policy" + allowed = tuple( + tool + for tool in candidates + if any( + fnmatch.fnmatchcase(tool.qualified_name, pattern) + for pattern in patterns + ) + ) + requested = context.run.input.get("tools") + if requested is None: + return allowed, None + if not isinstance(requested, list) or not all( + isinstance(item, str) for item in requested + ): + return (), "tools_must_be_an_array_of_names" + by_name = {tool.qualified_name: tool for tool in allowed} + if any(name not in by_name for name in requested): + return (), "requested_tool_not_available" + requested_set = set(requested) + return tuple( + tool for tool in allowed if tool.qualified_name in requested_set + ), None + + @staticmethod + def _transcript( + base: list[dict[str, Any]], + steps: tuple[RunStepRecord, ...], + ): + transcript = list(base) + by_action = {step.action_key: step for step in steps} + latest_model = None + for step in steps: + if step.kind != "model" or step.status is not RunStepStatus.COMPLETED: + continue + message, finish_reason, error = _model_message(step.output or {}) + if error is not None: + return transcript, None, None, error + latest_model = (step, message, finish_reason) + assistant = {"role": "assistant", "content": message.get("content")} + tool_calls = message.get("tool_calls") or [] + if tool_calls: + assistant["tool_calls"] = tool_calls + transcript.append(assistant) + for index, call in enumerate(tool_calls): + if not isinstance(call, dict): + return transcript, latest_model, None, "Malformed model Tool call" + call_id = call.get("id") or f"call-{index}" + action_key = _tool_action_key(step.sequence, index, str(call_id)) + tool_step = by_action.get(action_key) + if tool_step is None or tool_step.status is not RunStepStatus.COMPLETED: + continue + transcript.append( + { + "role": "tool", + "tool_call_id": str(call_id), + "content": json.dumps( + tool_step.output, + ensure_ascii=False, + sort_keys=True, + ), + } + ) + return transcript, latest_model, by_action, None + + @staticmethod + def _total_model_tokens(steps: tuple[RunStepRecord, ...]) -> int: + total = 0 + for step in steps: + if step.kind != "model" or step.status is not RunStepStatus.COMPLETED: + continue + usage = (step.output or {}).get("usage") + if not isinstance(usage, dict): + continue + value = usage.get("total_tokens") + if isinstance(value, int) and value > 0: + total += value + return total + + async def __call__(self, context: AgentExecutionContext) -> AgentAction: + ensured = await asyncio.to_thread(self._ensure_prompt, context) + if ensured is not None: + return FailAction(ensured, "Agent prompt must be non-empty text") + + base, base_error = await asyncio.to_thread(self._messages_for_run, context) + if base_error is not None: + return FailAction(base_error, "Input Message is not valid for this Run") + tools, tool_error = await asyncio.to_thread(self._available_tools, context) + if tool_error is not None: + return FailAction(tool_error, "Requested Tool selection is invalid") + aliases, tool_definitions = _tool_catalog(tools) + transcript, latest, by_action, transcript_error = self._transcript( + base, context.steps + ) + if transcript_error is not None: + return FailAction("invalid_model_response", transcript_error) + used_tokens = self._total_model_tokens(context.steps) + configured_budget = context.definition.manifest.get( + "max_total_model_tokens", 100_000 + ) + token_budget = ( + configured_budget + if isinstance(configured_budget, int) and configured_budget > 0 + else 100_000 + ) + delegated_budget = context.run.delegation.get("budget", {}).get( + "max_model_tokens" + ) + if isinstance(delegated_budget, int) and delegated_budget > 0: + token_budget = min(token_budget, delegated_budget) + run_budget = context.run.input.get("run_budget", {}).get("max_model_tokens") + if isinstance(run_budget, int) and run_budget > 0: + token_budget = min(token_budget, run_budget) + if used_tokens > token_budget: + return FailAction( + "model_token_budget_exceeded", + f"Agent model token budget exceeded ({used_tokens}/{token_budget})", + ) + + if latest is not None: + model_step, message, finish_reason = latest + tool_calls = message.get("tool_calls") or [] + if not tool_calls: + return await asyncio.to_thread( + self._complete, context, message, finish_reason + ) + if used_tokens >= token_budget: + return FailAction( + "model_token_budget_exceeded", + f"Agent model token budget exhausted ({used_tokens}/{token_budget})", + ) + for index, call in enumerate(tool_calls): + if not isinstance(call, dict) or not isinstance( + call.get("function"), dict + ): + return FailAction( + "malformed_tool_call", "Malformed model Tool call" + ) + call_id = str(call.get("id") or f"call-{index}") + action_key = _tool_action_key(model_step.sequence, index, call_id) + if action_key in by_action: + continue + function = call["function"] + alias = function.get("name") + tool = aliases.get(alias) if isinstance(alias, str) else None + if tool is None: + return FailAction( + "tool_not_available", + f"Model requested unavailable Tool alias: {alias}", + ) + arguments = function.get("arguments", {}) + if isinstance(arguments, str): + try: + arguments = json.loads(arguments or "{}") + except json.JSONDecodeError: + return FailAction( + "invalid_tool_arguments", + f"Model returned invalid JSON for {tool.qualified_name}", + ) + if not isinstance(arguments, dict): + return FailAction( + "invalid_tool_arguments", + f"Arguments for {tool.qualified_name} must be an object", + ) + repeats = 0 + completed_tools = [ + step + for step in context.steps + if step.kind == "tool" and step.status is RunStepStatus.COMPLETED + ] + for prior in reversed(completed_tools): + if ( + prior.tool_name != tool.qualified_name + or prior.input != arguments + ): + break + repeats += 1 + configured_repeats = context.definition.manifest.get( + "max_repeated_tool_calls", 3 + ) + max_repeats = ( + configured_repeats + if isinstance(configured_repeats, int) and configured_repeats > 0 + else 3 + ) + if repeats >= max_repeats: + return FailAction( + "repeated_tool_call", + f"Repeated Tool call limit reached for {tool.qualified_name}", + ) + return ToolCallAction( + call_id=action_key, + tool_name=tool.qualified_name, + arguments=arguments, + timeout_ms=tool.timeout_ms, + ) + + round_number = 1 + sum(step.kind == "model" for step in context.steps) + request: dict[str, Any] = { + "model": context.run.input.get("model", ""), + "messages": transcript, + "ai2apps_idempotency_key": ( + f"agent-{context.run.id}-model-{round_number}" + ), + } + if tool_definitions: + request["tools"] = tool_definitions + request["tool_choice"] = "auto" + options = context.run.input.get("model_options", {}) + if not isinstance(options, dict): + return FailAction( + "invalid_model_options", "model_options must be an object" + ) + request.update( + {key: value for key, value in options.items() if key in _MODEL_OPTION_KEYS} + ) + return ModelCallAction(call_id=f"model:{round_number}", request=request) + + def _complete(self, context, message, finish_reason) -> CompleteAction: + content = message.get("content") + if content is None: + content = "" + generated_images = [] + for step in context.steps: + if ( + step.kind != "tool" + or step.status is not RunStepStatus.COMPLETED + or step.tool_name != "image.generate" + or not isinstance(step.output, dict) + ): + continue + artifact = step.output.get("artifact") + url = artifact.get("download_url") if isinstance(artifact, dict) else None + if isinstance(url, str) and url: + generated_images.append( + { + "type": "image_url", + "image_url": { + "url": url, + "artifact_id": artifact.get("id"), + "filename": artifact.get("name"), + }, + } + ) + if generated_images: + text_parts = [] + if isinstance(content, str) and content: + text_parts.append({"type": "text", "text": content}) + elif isinstance(content, list): + text_parts.extend(content) + content = [*text_parts, *generated_images] + latest_model = next( + step + for step in reversed(context.steps) + if step.kind == "model" and step.status is RunStepStatus.COMPLETED + ) + usage = (latest_model.output or {}).get("usage") + cloud_lifecycle = [ + item + for step in context.steps + if ( + step.kind == "model" + or (step.kind == "tool" and step.tool_name == "image.generate") + ) + and isinstance(step.output, dict) + for item in (step.output.get("ai2apps_cloud") or []) + if isinstance(item, dict) + ] + if context.run.parent_run_id is not None: + return CompleteAction( + { + "content": content, + "finish_reason": finish_reason, + "usage": usage, + "ai2apps_cloud": cloud_lifecycle, + "total_model_tokens": self._total_model_tokens(context.steps), + } + ) + part = ( + MessagePartInput(kind="text", content={"text": content}) + if isinstance(content, str) + else MessagePartInput(kind="openai_content", content={"content": content}) + ) + result = self.messages.append( + session_id=context.run.session_id, + role=MessageRole.ASSISTANT, + parts=(part,), + idempotency_key=f"agent-run:{context.run.id}:assistant", + metadata={ + "agent_run_id": context.run.id, + "agent_definition_id": context.definition.id, + "ai2apps_cloud": cloud_lifecycle, + }, + trace_id=context.run.id, + ) + return CompleteAction( + { + "message_id": result.value.message.id, + "content": content, + "finish_reason": finish_reason, + "usage": usage, + "ai2apps_cloud": cloud_lifecycle, + "total_model_tokens": self._total_model_tokens(context.steps), + } + ) + + +def install_general_agent( + repository: AgentRepository, + runtime: AgentRuntime, + database: PlatformDatabase, + events: EventStore, + tools: ToolGateway, +) -> None: + repository.ensure_definition( + agent_key="ai2apps.general-agent", + package_version="1.0.0", + display_name="General Agent", + description="Durable model and Tool loop for conversation Sessions.", + executor_key="builtin:general-agent", + concurrency_group="model:foreground", + concurrency_limit=1, + max_steps=24, + timeout_seconds=900, + manifest={ + "builtin": True, + "discoverable": True, + "aliases": ["general", "agent"], + "invocation_schema": {"type": "object", "properties": {}}, + "allowed_tools": ["*"], + "context_message_limit": 200, + "max_total_model_tokens": 100_000, + "max_repeated_tool_calls": 3, + }, + ) + runtime.bind_executor( + "builtin:general-agent", + GeneralAgentExecutor(database, events, tools), + ) diff --git a/ai2apps/agents/model_stream.py b/ai2apps/agents/model_stream.py new file mode 100644 index 00000000..96a3f538 --- /dev/null +++ b/ai2apps/agents/model_stream.py @@ -0,0 +1,161 @@ +"""Utilities for consuming an OpenAI chat-completion stream for an Agent. + +The Agent runtime still needs a complete response so it can durably decide +whether to call a Tool or finish the Run. This accumulator lets the provider +consume the response as a stream (and report progress) without changing that +decision contract. +""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + + +def _append_text(target: dict[str, Any], source: dict[str, Any], key: str) -> None: + value = source.get(key) + if isinstance(value, str): + target[key] = str(target.get(key) or "") + value + + +class ChatCompletionStreamAccumulator: + """Reassemble OpenAI-compatible SSE chunks into one completion object.""" + + def __init__(self) -> None: + self._root: dict[str, Any] = {} + self._choices: dict[int, dict[str, Any]] = {} + self._cloud_lifecycle: list[dict[str, Any]] = [] + self._cloud_failure: dict[str, Any] | None = None + self.output_characters = 0 + self.fragments = 0 + self.has_tool_calls = False + + def add(self, chunk: dict[str, Any]) -> None: + for key in ( + "id", + "created", + "model", + "system_fingerprint", + "service_tier", + ): + if key in chunk and chunk[key] is not None: + self._root[key] = chunk[key] + if isinstance(chunk.get("usage"), dict): + self._root["usage"] = deepcopy(chunk["usage"]) + + choices = chunk.get("choices") + if not isinstance(choices, list): + return + for raw_choice in choices: + if not isinstance(raw_choice, dict): + continue + index = raw_choice.get("index", 0) + if not isinstance(index, int): + index = 0 + choice = self._choices.setdefault( + index, + { + "index": index, + "message": {"role": "assistant", "content": ""}, + "finish_reason": None, + }, + ) + delta = raw_choice.get("delta") + if not isinstance(delta, dict): + delta = {} + cloud = delta.get("ai2apps_cloud") + if isinstance(cloud, dict): + self._cloud_lifecycle.append(deepcopy(cloud)) + if cloud.get("phase") == "failed": + error = cloud.get("error") + self._cloud_failure = deepcopy(error if isinstance(error, dict) else cloud) + message = choice["message"] + if isinstance(delta.get("role"), str): + message["role"] = delta["role"] + for key in ("content", "reasoning_content", "refusal"): + before = len(str(message.get(key) or "")) + _append_text(message, delta, key) + after = len(str(message.get(key) or "")) + self.output_characters += after - before + if any(isinstance(delta.get(key), str) for key in ("content", "reasoning_content")): + self.fragments += 1 + + tool_deltas = delta.get("tool_calls") + if isinstance(tool_deltas, list): + self.has_tool_calls = self.has_tool_calls or bool(tool_deltas) + tools = message.setdefault("tool_calls", []) + by_index = { + item["index"]: item + for item in tools + if isinstance(item, dict) and isinstance(item.get("index"), int) + } + for tool_delta in tool_deltas: + if not isinstance(tool_delta, dict): + continue + tool_index = tool_delta.get("index", 0) + if not isinstance(tool_index, int): + tool_index = 0 + tool = by_index.get(tool_index) + if tool is None: + tool = { + "index": tool_index, + "id": "", + "type": "", + "function": {"name": "", "arguments": ""}, + } + tools.append(tool) + by_index[tool_index] = tool + if isinstance(tool_delta.get("id"), str): + tool["id"] = str(tool.get("id") or "") + tool_delta["id"] + if isinstance(tool_delta.get("type"), str): + tool["type"] = tool_delta["type"] + function = tool_delta.get("function") + if isinstance(function, dict): + _append_text(tool["function"], function, "name") + _append_text(tool["function"], function, "arguments") + + if raw_choice.get("finish_reason") is not None: + choice["finish_reason"] = raw_choice["finish_reason"] + if raw_choice.get("logprobs") is not None: + choice["logprobs"] = deepcopy(raw_choice["logprobs"]) + + def result(self) -> dict[str, Any]: + if self._cloud_failure is not None: + code = str(self._cloud_failure.get("code") or "AI2APPS_CLOUD_REQUEST_FAILED") + message = str(self._cloud_failure.get("message") or code) + raise ValueError(f"{code}: {message}") + if not self._choices: + raise ValueError("Model Runtime stream did not contain a completion choice") + choices = [] + for index in sorted(self._choices): + choice = deepcopy(self._choices[index]) + message = choice["message"] + tools = message.get("tool_calls") + if isinstance(tools, list): + tools.sort(key=lambda item: item.get("index", 0)) + for tool in tools: + tool.pop("index", None) + if not tool.get("type"): + tool["type"] = "function" + if not message.get("reasoning_content"): + message.pop("reasoning_content", None) + if not message.get("refusal"): + message.pop("refusal", None) + choices.append(choice) + return { + "id": self._root.get("id", ""), + "object": "chat.completion", + "created": self._root.get("created", 0), + "model": self._root.get("model", ""), + "choices": choices, + **{ + key: deepcopy(self._root[key]) + for key in ("system_fingerprint", "service_tier", "usage") + if key in self._root + }, + **( + {"ai2apps_cloud": deepcopy(self._cloud_lifecycle)} + if self._cloud_lifecycle + else {} + ), + } diff --git a/ai2apps/agents/models.py b/ai2apps/agents/models.py new file mode 100644 index 00000000..79c08d29 --- /dev/null +++ b/ai2apps/agents/models.py @@ -0,0 +1,238 @@ +"""Durable contracts for asynchronous Agent execution and interaction.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum +from typing import Any + + +class AgentDefinitionStatus(StrEnum): + ENABLED = "enabled" + DISABLED = "disabled" + + +class AgentRunStatus(StrEnum): + QUEUED = "queued" + PLANNING = "planning" + RUNNING = "running" + WAITING_INPUT = "waiting_input" + WAITING_CAPABILITY = "waiting_capability" + INTERRUPTED = "interrupted" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class RunStepStatus(StrEnum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + UNCERTAIN = "uncertain" + + +class InteractionKind(StrEnum): + TEXT = "text" + MENU = "menu" + FILE = "file" + FORM = "form" + APPROVAL = "approval" + + +class InteractionStatus(StrEnum): + PENDING = "pending" + SUBMITTED = "submitted" + APPROVED = "approved" + DENIED = "denied" + EXPIRED = "expired" + CANCELLED = "cancelled" + + +@dataclass(frozen=True, slots=True) +class AgentDefinitionRecord: + id: str + agent_key: str + package_version: str + display_name: str + description: str + source: str + status: AgentDefinitionStatus + executor_key: str + concurrency_group: str | None + concurrency_limit: int | None + resume_policy: str + max_steps: int + timeout_seconds: int + manifest: dict[str, Any] + revision: int + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class AgentRunRecord: + id: str + agent_definition_id: str + session_id: str + parent_run_id: str | None + root_run_id: str + depth: int + delegation: dict[str, Any] + status: AgentRunStatus + idempotency_key: str | None + priority: int + input: dict[str, Any] + output: dict[str, Any] | None + error: dict[str, Any] | None + granted_capabilities: tuple[str, ...] + current_step: int + cancel_requested: bool + revision: int + deadline_at: datetime + created_at: datetime + updated_at: datetime + started_at: datetime | None + finished_at: datetime | None + + +@dataclass(frozen=True, slots=True) +class StatusLineRecord: + id: str + run_id: str + status_key: str + phase: str + text: str + presentation: str + progress: float | None + content: dict[str, Any] + revision: int + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class RunStepRecord: + id: str + run_id: str + sequence: int + action_key: str + kind: str + status: RunStepStatus + tool_name: str | None + input: dict[str, Any] + output: dict[str, Any] | None + error: dict[str, Any] | None + created_at: datetime + started_at: datetime | None + finished_at: datetime | None + + +@dataclass(frozen=True, slots=True) +class InteractionRecord: + id: str + run_id: str + request_key: str + kind: InteractionKind + status: InteractionStatus + prompt: str + response_schema: dict[str, Any] + ui_hints: dict[str, Any] + request: dict[str, Any] + response: dict[str, Any] | None + response_id: str | None + deadline_at: datetime + revision: int + created_at: datetime + updated_at: datetime + resolved_at: datetime | None + + +@dataclass(frozen=True, slots=True) +class CompleteAction: + output: dict[str, Any] + + +@dataclass(frozen=True, slots=True) +class FailAction: + code: str + message: str + retryable: bool = False + + +@dataclass(frozen=True, slots=True) +class ContinueAction: + status_text: str = "Continuing…" + + +@dataclass(frozen=True, slots=True) +class StatusAction: + phase: str + text: str + presentation: str = "pulse" + progress: float | None = None + content: dict[str, Any] | None = None + + +@dataclass(frozen=True, slots=True) +class InteractionAction: + request_key: str + kind: InteractionKind + prompt: str + response_schema: dict[str, Any] + ui_hints: dict[str, Any] | None = None + request: dict[str, Any] | None = None + timeout_seconds: int = 86_400 + + +@dataclass(frozen=True, slots=True) +class ToolCallAction: + call_id: str + tool_name: str + arguments: dict[str, Any] + timeout_ms: int | None = None + + +@dataclass(frozen=True, slots=True) +class ModelCallAction: + call_id: str + request: dict[str, Any] + + +AgentAction = ( + CompleteAction + | FailAction + | ContinueAction + | StatusAction + | InteractionAction + | ToolCallAction + | ModelCallAction +) + + +@dataclass(frozen=True, slots=True) +class AgentExecutionContext: + definition: AgentDefinitionRecord + run: AgentRunRecord + steps: tuple[RunStepRecord, ...] + interactions: tuple[InteractionRecord, ...] + + def interaction(self, request_key: str) -> InteractionRecord | None: + return next( + (item for item in self.interactions if item.request_key == request_key), + None, + ) + + def step(self, action_key: str) -> RunStepRecord | None: + return next( + (item for item in self.steps if item.action_key == action_key), None + ) + + +class AgentRuntimeError(RuntimeError): + def __init__(self, code: str, message: str, *, details=None) -> None: + self.code = code + self.details = details or {} + super().__init__(message) diff --git a/ai2apps/agents/repository.py b/ai2apps/agents/repository.py new file mode 100644 index 00000000..746378d5 --- /dev/null +++ b/ai2apps/agents/repository.py @@ -0,0 +1,1963 @@ +"""Transactional persistence and state machine for asynchronous AgentRuns.""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import UTC, datetime, timedelta +from typing import Any + +from jsonschema import Draft202012Validator, ValidationError + +from ai2apps.capabilities import CapabilityRepository, GrantScope +from ai2apps.core import ( + EntityIdKind, + ResourceConflictError, + ResourceNotFoundError, + format_utc, + new_entity_id, + parse_utc, + utc_now_text, +) +from ai2apps.events import EventStore +from ai2apps.storage import PlatformDatabase + +from .models import ( + AgentDefinitionRecord, + AgentDefinitionStatus, + AgentRunRecord, + AgentRunStatus, + InteractionKind, + InteractionRecord, + InteractionStatus, + RunStepRecord, + RunStepStatus, + StatusLineRecord, +) + + +def _json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def _optional_json(value: str | None): + return None if value is None else json.loads(value) + + +def _optional_time(value: str | None): + return None if value is None else parse_utc(value) + + +def _resume_deadline(deadline: str, suspended_at: str, now: datetime) -> str: + """Freeze an execution deadline while a durable Run awaits external action.""" + + paused_for = max(timedelta(0), now - parse_utc(suspended_at)) + return format_utc(parse_utc(deadline) + paused_for) + + +class AgentRepository: + MAX_DELEGATION_DEPTH = 2 + MAX_CHILD_RUNS = 4 + MAX_RUN_RETRIES = 3 + + def __init__( + self, + database: PlatformDatabase, + events: EventStore, + capabilities: CapabilityRepository | None = None, + ) -> None: + self.database = database + self.events = events + self.capabilities = capabilities + + @staticmethod + def _definition(row) -> AgentDefinitionRecord: + return AgentDefinitionRecord( + id=row["id"], + agent_key=row["agent_key"], + package_version=row["package_version"], + display_name=row["display_name"], + description=row["description"], + source=row["source"], + status=AgentDefinitionStatus(row["status"]), + executor_key=row["executor_key"], + concurrency_group=row["concurrency_group"], + concurrency_limit=row["concurrency_limit"], + resume_policy=row["resume_policy"], + max_steps=row["max_steps"], + timeout_seconds=row["timeout_seconds"], + manifest=json.loads(row["manifest_json"]), + revision=row["revision"], + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + ) + + @staticmethod + def _run(row) -> AgentRunRecord: + return AgentRunRecord( + id=row["id"], + agent_definition_id=row["agent_definition_id"], + session_id=row["session_id"], + parent_run_id=row["parent_run_id"], + root_run_id=row["root_run_id"] or row["id"], + depth=row["depth"], + delegation=json.loads(row["delegation_json"]), + status=AgentRunStatus(row["status"]), + idempotency_key=row["idempotency_key"], + priority=row["priority"], + input=json.loads(row["input_json"]), + output=_optional_json(row["output_json"]), + error=_optional_json(row["error_json"]), + granted_capabilities=tuple(json.loads(row["granted_capabilities_json"])), + current_step=row["current_step"], + cancel_requested=bool(row["cancel_requested"]), + revision=row["revision"], + deadline_at=parse_utc(row["deadline_at"]), + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + started_at=_optional_time(row["started_at"]), + finished_at=_optional_time(row["finished_at"]), + ) + + @staticmethod + def _status(row) -> StatusLineRecord: + return StatusLineRecord( + id=row["id"], + run_id=row["run_id"], + status_key=row["status_key"], + phase=row["phase"], + text=row["text"], + presentation=row["presentation"], + progress=row["progress"], + content=json.loads(row["content_json"]), + revision=row["revision"], + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + ) + + @staticmethod + def _step(row) -> RunStepRecord: + return RunStepRecord( + id=row["id"], + run_id=row["run_id"], + sequence=row["sequence"], + action_key=row["action_key"], + kind=row["kind"], + status=RunStepStatus(row["status"]), + tool_name=row["tool_name"], + input=json.loads(row["input_json"]), + output=_optional_json(row["output_json"]), + error=_optional_json(row["error_json"]), + created_at=parse_utc(row["created_at"]), + started_at=_optional_time(row["started_at"]), + finished_at=_optional_time(row["finished_at"]), + ) + + @staticmethod + def _interaction(row) -> InteractionRecord: + return InteractionRecord( + id=row["id"], + run_id=row["run_id"], + request_key=row["request_key"], + kind=InteractionKind(row["kind"]), + status=InteractionStatus(row["status"]), + prompt=row["prompt"], + response_schema=json.loads(row["response_schema_json"]), + ui_hints=json.loads(row["ui_hints_json"]), + request=json.loads(row["request_json"]), + response=_optional_json(row["response_json"]), + response_id=row["response_id"], + deadline_at=parse_utc(row["deadline_at"]), + revision=row["revision"], + created_at=parse_utc(row["created_at"]), + updated_at=parse_utc(row["updated_at"]), + resolved_at=_optional_time(row["resolved_at"]), + ) + + @staticmethod + def _definition_query() -> str: + return """ + SELECT d.*, g.concurrency_limit + FROM agent_definitions d + LEFT JOIN agent_concurrency_groups g + ON g.group_key = d.concurrency_group + """ + + def ensure_definition( + self, + *, + agent_key: str, + package_version: str, + display_name: str, + executor_key: str, + description: str = "", + source: str = "builtin", + concurrency_group: str | None = None, + concurrency_limit: int | None = None, + resume_policy: str = "restart", + max_steps: int = 20, + timeout_seconds: int = 300, + manifest: dict[str, Any] | None = None, + ) -> AgentDefinitionRecord: + if (concurrency_group is None) != (concurrency_limit is None): + raise ValueError( + "concurrency_group and concurrency_limit must be set together" + ) + if concurrency_limit is not None and concurrency_limit <= 0: + raise ValueError("concurrency_limit must be positive") + manifest_data = manifest or {} + invocation_schema = manifest_data.get( + "invocation_schema", {"type": "object", "properties": {}} + ) + if not isinstance(invocation_schema, dict): + raise ValueError("Agent invocation_schema must be a JSON object") + Draft202012Validator.check_schema(invocation_schema) + if invocation_schema.get("type", "object") != "object": + raise ValueError("Agent invocation_schema must describe an object") + definition_id = new_entity_id(EntityIdKind.AGENT_DEFINITION) + now = utc_now_text() + try: + with self.database.transaction(write=True) as connection: + if concurrency_group is not None: + group = connection.execute( + "SELECT concurrency_limit FROM agent_concurrency_groups WHERE group_key = ?", + (concurrency_group,), + ).fetchone() + if group is None: + connection.execute( + """ + INSERT INTO agent_concurrency_groups( + group_key, concurrency_limit, created_at, updated_at + ) VALUES (?, ?, ?, ?) + """, + (concurrency_group, concurrency_limit, now, now), + ) + elif group["concurrency_limit"] != concurrency_limit: + raise ResourceConflictError( + f"Concurrency group {concurrency_group} already has limit " + f"{group['concurrency_limit']}" + ) + row = connection.execute( + "SELECT * FROM agent_definitions WHERE agent_key = ?", + (agent_key,), + ).fetchone() + if row is None: + connection.execute( + """ + INSERT INTO agent_definitions( + id, agent_key, package_version, display_name, description, + source, executor_key, concurrency_group, resume_policy, + max_steps, timeout_seconds, manifest_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + definition_id, + agent_key, + package_version, + display_name, + description, + source, + executor_key, + concurrency_group, + resume_policy, + max_steps, + timeout_seconds, + _json(manifest_data), + now, + now, + ), + ) + self.events.append_in_transaction( + connection, + event_type="agent.definition.registered", + subject_id=definition_id, + payload={"agent_key": agent_key}, + ) + else: + definition_id = row["id"] + if row["executor_key"] != executor_key: + raise ResourceConflictError( + f"Agent {agent_key} is owned by another executor" + ) + if source == "builtin" and row["source"] == "builtin": + connection.execute( + """ + UPDATE agent_definitions SET + package_version = ?, display_name = ?, description = ?, + concurrency_group = ?, resume_policy = ?, max_steps = ?, + timeout_seconds = ?, manifest_json = ?, + revision = revision + 1, updated_at = ? + WHERE id = ? AND ( + package_version != ? OR display_name != ? OR + description != ? OR concurrency_group IS NOT ? OR + resume_policy != ? OR max_steps != ? OR + timeout_seconds != ? OR manifest_json != ? + ) + """, + ( + package_version, + display_name, + description, + concurrency_group, + resume_policy, + max_steps, + timeout_seconds, + _json(manifest_data), + now, + definition_id, + package_version, + display_name, + description, + concurrency_group, + resume_policy, + max_steps, + timeout_seconds, + _json(manifest_data), + ), + ) + row = connection.execute( + self._definition_query() + " WHERE d.id = ?", + (definition_id,), + ).fetchone() + assert row is not None + return self._definition(row) + except sqlite3.IntegrityError as exc: + raise ResourceConflictError(str(exc)) from exc + + def get_definition(self, agent_id_or_key: str) -> AgentDefinitionRecord: + with self.database.transaction() as connection: + row = connection.execute( + self._definition_query() + " WHERE d.id = ? OR d.agent_key = ?", + (agent_id_or_key, agent_id_or_key), + ).fetchone() + if row is None: + raise ResourceNotFoundError("agent_definition", agent_id_or_key) + return self._definition(row) + + def list_definitions(self) -> tuple[AgentDefinitionRecord, ...]: + with self.database.transaction() as connection: + rows = connection.execute( + self._definition_query() + " ORDER BY d.agent_key" + ).fetchall() + return tuple(self._definition(row) for row in rows) + + def set_definition_status( + self, agent_id_or_key: str, status: AgentDefinitionStatus + ) -> AgentDefinitionRecord: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + row = connection.execute( + "SELECT * FROM agent_definitions WHERE id = ? OR agent_key = ?", + (agent_id_or_key, agent_id_or_key), + ).fetchone() + if row is None: + raise ResourceNotFoundError("agent_definition", agent_id_or_key) + if row["status"] != status.value: + connection.execute( + """ + UPDATE agent_definitions SET status = ?, revision = revision + 1, + updated_at = ? WHERE id = ? + """, + (status.value, now, row["id"]), + ) + self.events.append_in_transaction( + connection, + event_type=f"agent.definition.{status.value}", + subject_id=row["id"], + payload={"agent_key": row["agent_key"], "status": status.value}, + ) + updated = connection.execute( + self._definition_query() + " WHERE d.id = ?", (row["id"],) + ).fetchone() + assert updated is not None + return self._definition(updated) + + def list_runs( + self, + *, + agent_definition_id: str | None = None, + status: AgentRunStatus | None = None, + root_only: bool = False, + limit: int = 100, + offset: int = 0, + ) -> tuple[AgentRunRecord, ...]: + if limit <= 0 or limit > 500: + raise ValueError("AgentRun limit must be between 1 and 500") + if offset < 0: + raise ValueError("AgentRun offset must be non-negative") + clauses: list[str] = [] + parameters: list[Any] = [] + if agent_definition_id is not None: + clauses.append("agent_definition_id = ?") + parameters.append(agent_definition_id) + if status is not None: + clauses.append("status = ?") + parameters.append(status.value) + if root_only: + clauses.append("parent_run_id IS NULL") + where = " WHERE " + " AND ".join(clauses) if clauses else "" + parameters.extend((limit, offset)) + with self.database.transaction() as connection: + rows = connection.execute( + "SELECT * FROM agent_runs" + + where + + " ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?", + parameters, + ).fetchall() + return tuple(self._run(row) for row in rows) + + def run_counts(self, agent_definition_id: str) -> dict[str, int]: + with self.database.transaction() as connection: + rows = connection.execute( + """ + SELECT status, COUNT(*) AS count FROM agent_runs + WHERE agent_definition_id = ? GROUP BY status + """, + (agent_definition_id,), + ).fetchall() + counts = {status.value: 0 for status in AgentRunStatus} + counts.update({row["status"]: row["count"] for row in rows}) + counts["total"] = sum(row["count"] for row in rows) + counts["active"] = sum( + counts[key] + for key in ( + "queued", + "planning", + "running", + "waiting_input", + "waiting_capability", + ) + ) + return counts + + def create_run( + self, + *, + session_id: str, + agent_key: str, + input: dict[str, Any], + idempotency_key: str | None = None, + priority: int = 0, + trace_id: str | None = None, + parent_run_id: str | None = None, + delegation: dict[str, Any] | None = None, + budget: dict[str, int] | None = None, + ) -> tuple[AgentRunRecord, bool]: + definition = self.get_definition(agent_key) + if definition.status is not AgentDefinitionStatus.ENABLED: + raise ResourceConflictError(f"Agent is disabled: {agent_key}") + invocation_schema = definition.manifest.get( + "invocation_schema", {"type": "object", "properties": {}} + ) + Draft202012Validator.check_schema(invocation_schema) + parameters = input.get("parameters", {}) + if not isinstance(parameters, dict): + raise ValueError("Agent parameters must be a JSON object") + try: + Draft202012Validator(invocation_schema).validate(parameters) + except ValidationError as error: + path = ".".join(str(item) for item in error.absolute_path) + prefix = f"Agent parameter {path}: " if path else "Agent parameters: " + raise ValueError(prefix + error.message) from error + caller_invocation = input.get("invocation", {}) + invocation_source = ( + caller_invocation.get("source", "api") + if isinstance(caller_invocation, dict) + else "api" + ) + delegation_data = dict(delegation or {}) + budget_data = dict(budget or {}) + for key in ("max_steps", "timeout_seconds", "max_model_tokens"): + value = budget_data.get(key) + if value is not None and (not isinstance(value, int) or value <= 0): + raise ValueError(f"Agent Run budget {key} must be a positive integer") + normalized_input = { + **input, + "parameters": parameters, + "invocation": { + "agent_definition_id": definition.id, + "agent_key": definition.agent_key, + "package_version": definition.package_version, + "source": str(invocation_source)[:64], + }, + **({"run_budget": budget_data} if budget_data else {}), + } + run_id = new_entity_id(EntityIdKind.AGENT_RUN) + status_id = new_entity_id(EntityIdKind.STATUS_LINE) + now_dt = datetime.now(UTC) + now = format_utc(now_dt) + timeout_seconds = min( + definition.timeout_seconds, + budget_data.get("timeout_seconds", definition.timeout_seconds), + ) + deadline = format_utc(now_dt + timedelta(seconds=timeout_seconds)) + try: + with self.database.transaction(write=True) as connection: + session = connection.execute( + "SELECT app_instance_id FROM sessions WHERE id = ? AND status = 'active'", + (session_id,), + ).fetchone() + if session is None: + raise ResourceNotFoundError("active_session", session_id) + root_run_id = run_id + depth = 0 + if parent_run_id is not None: + parent_row = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (parent_run_id,) + ).fetchone() + if parent_row is None: + raise ResourceNotFoundError("parent_agent_run", parent_run_id) + parent = self._run(parent_row) + if parent.session_id != session_id: + raise ResourceConflictError( + "Delegated AgentRun must use its parent's Session" + ) + if parent.status not in { + AgentRunStatus.PLANNING, + AgentRunStatus.RUNNING, + }: + raise ResourceConflictError( + "Delegation requires an active parent AgentRun" + ) + depth = parent.depth + 1 + if depth > self.MAX_DELEGATION_DEPTH: + raise ResourceConflictError( + f"Delegation depth exceeds {self.MAX_DELEGATION_DEPTH}" + ) + child_count = connection.execute( + "SELECT COUNT(*) FROM agent_runs WHERE parent_run_id = ?", + (parent_run_id,), + ).fetchone()[0] + if child_count >= self.MAX_CHILD_RUNS: + raise ResourceConflictError( + f"Parent AgentRun already has {self.MAX_CHILD_RUNS} children" + ) + root_run_id = parent.root_run_id + delegated_timeout = delegation_data.get("budget", {}).get( + "timeout_seconds", definition.timeout_seconds + ) + if not isinstance(delegated_timeout, int) or delegated_timeout <= 0: + raise ValueError("Delegation timeout_seconds must be positive") + child_deadline = now_dt + timedelta( + seconds=min(delegated_timeout, timeout_seconds) + ) + deadline = format_utc(min(child_deadline, parent.deadline_at)) + delegation_data = { + **delegation_data, + "parent_run_id": parent.id, + "root_run_id": root_run_id, + "depth": depth, + } + normalized_input["delegation"] = delegation_data + if idempotency_key is not None: + existing = connection.execute( + """ + SELECT * FROM agent_runs + WHERE session_id = ? AND idempotency_key = ? + """, + (session_id, idempotency_key), + ).fetchone() + if existing is not None: + if existing["agent_definition_id"] != definition.id or existing[ + "input_json" + ] not in {_json(input), _json(normalized_input)}: + raise ResourceConflictError( + "AgentRun idempotency key was reused with different input" + ) + return self._run(existing), False + connection.execute( + """ + INSERT INTO agent_runs( + id, agent_definition_id, session_id, idempotency_key, + priority, input_json, deadline_at, created_at, updated_at, + parent_run_id, root_run_id, depth, delegation_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + run_id, + definition.id, + session_id, + idempotency_key, + priority, + _json(normalized_input), + deadline, + now, + now, + parent_run_id, + root_run_id, + depth, + _json(delegation_data), + ), + ) + if parent_run_id is not None: + request_key = delegation_data.get("request_key") + task = delegation_data.get("task") + if not isinstance(request_key, str) or not request_key: + raise ValueError("Delegation request_key is required") + if not isinstance(task, str) or not task.strip(): + raise ValueError("Delegation task is required") + connection.execute( + """ + INSERT INTO agent_delegations( + id, parent_run_id, child_run_id, request_key, + target_agent_key, task, parameters_json, + context_json, budget_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + new_entity_id(EntityIdKind.AGENT_DELEGATION), + parent_run_id, + run_id, + request_key, + definition.agent_key, + task.strip(), + _json(delegation_data.get("parameters", {})), + _json(delegation_data.get("context", {})), + _json(delegation_data.get("budget", {})), + now, + now, + ), + ) + connection.execute( + """ + INSERT INTO agent_status_lines( + id, run_id, phase, text, presentation, + created_at, updated_at + ) VALUES (?, ?, 'queued', 'Queued', 'pulse', ?, ?) + """, + (status_id, run_id, now, now), + ) + self.events.append_in_transaction( + connection, + event_type="agent.run.queued", + subject_id=run_id, + app_instance_id=session["app_instance_id"], + session_id=session_id, + trace_id=trace_id, + payload={ + "agent_key": definition.agent_key, + "status": "queued", + "status_line": {"text": "Queued", "presentation": "pulse"}, + "parent_run_id": parent_run_id, + "root_run_id": root_run_id, + "depth": depth, + }, + ) + row = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (run_id,) + ).fetchone() + assert row is not None + return self._run(row), True + except sqlite3.IntegrityError as exc: + raise ResourceConflictError(str(exc)) from exc + + def get_run(self, run_id: str) -> AgentRunRecord: + with self.database.transaction() as connection: + row = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (run_id,) + ).fetchone() + if row is None: + raise ResourceNotFoundError("agent_run", run_id) + return self._run(row) + + def retry_run( + self, + run_id: str, + *, + idempotency_key: str | None = None, + trace_id: str | None = None, + ) -> tuple[AgentRunRecord, bool]: + """Create a fresh, auditable attempt from a failed or cancelled Run.""" + + original = self.get_run(run_id) + if original.status not in {AgentRunStatus.FAILED, AgentRunStatus.CANCELLED}: + raise ResourceConflictError( + "Only a failed or cancelled AgentRun can be retried" + ) + retry_metadata = original.input.get("retry") + root_attempt_run_id = ( + retry_metadata.get("root_attempt_run_id", original.id) + if isinstance(retry_metadata, dict) + else original.id + ) + with self.database.transaction() as connection: + if idempotency_key is not None: + existing = connection.execute( + """ + SELECT * FROM agent_runs + WHERE session_id = ? AND idempotency_key = ? + """, + (original.session_id, idempotency_key), + ).fetchone() + if existing is not None: + retry = json.loads(existing["input_json"]).get("retry", {}) + if retry.get("retry_of_run_id") != original.id: + raise ResourceConflictError( + "AgentRun retry idempotency key was reused" + ) + return self._run(existing), False + previous_attempts = int( + connection.execute( + """ + SELECT COUNT(*) FROM agent_runs + WHERE session_id = ? + AND json_extract(input_json, '$.retry.root_attempt_run_id') = ? + """, + (original.session_id, root_attempt_run_id), + ).fetchone()[0] + ) + attempt = previous_attempts + 1 + if attempt > self.MAX_RUN_RETRIES: + raise ResourceConflictError( + f"AgentRun retry limit reached ({self.MAX_RUN_RETRIES})" + ) + definition = self.get_definition(original.agent_definition_id) + copied_input = dict(original.input) + copied_input.pop("invocation", None) + copied_input.pop("delegation", None) + copied_input["retry"] = { + "attempt": attempt, + "retry_of_run_id": original.id, + "root_attempt_run_id": root_attempt_run_id, + } + return self.create_run( + session_id=original.session_id, + agent_key=definition.agent_key, + input=copied_input, + idempotency_key=idempotency_key, + priority=original.priority, + trace_id=trace_id, + ) + + def list_children(self, run_id: str) -> tuple[AgentRunRecord, ...]: + with self.database.transaction() as connection: + rows = connection.execute( + "SELECT * FROM agent_runs WHERE parent_run_id = ? ORDER BY created_at, id", + (run_id,), + ).fetchall() + return tuple(self._run(row) for row in rows) + + def list_descendants(self, run_id: str) -> tuple[AgentRunRecord, ...]: + with self.database.transaction() as connection: + rows = connection.execute( + """ + WITH RECURSIVE descendants(id) AS ( + SELECT id FROM agent_runs WHERE parent_run_id = ? + UNION ALL + SELECT child.id FROM agent_runs child + JOIN descendants parent ON child.parent_run_id = parent.id + ) + SELECT run.* FROM agent_runs run + JOIN descendants ON descendants.id = run.id + ORDER BY run.depth, run.created_at, run.id + """, + (run_id,), + ).fetchall() + return tuple(self._run(row) for row in rows) + + def get_delegated_child( + self, parent_run_id: str, request_key: str + ) -> AgentRunRecord | None: + with self.database.transaction() as connection: + row = connection.execute( + """ + SELECT child.* FROM agent_delegations delegation + JOIN agent_runs child ON child.id = delegation.child_run_id + WHERE delegation.parent_run_id = ? AND delegation.request_key = ? + """, + (parent_run_id, request_key), + ).fetchone() + return None if row is None else self._run(row) + + def get_status_line(self, run_id: str) -> StatusLineRecord: + with self.database.transaction() as connection: + row = connection.execute( + "SELECT * FROM agent_status_lines WHERE run_id = ? AND status_key = 'primary'", + (run_id,), + ).fetchone() + if row is None: + raise ResourceNotFoundError("agent_status_line", run_id) + return self._status(row) + + def list_steps(self, run_id: str) -> tuple[RunStepRecord, ...]: + with self.database.transaction() as connection: + rows = connection.execute( + "SELECT * FROM run_steps WHERE run_id = ? ORDER BY sequence", + (run_id,), + ).fetchall() + return tuple(self._step(row) for row in rows) + + def list_interactions(self, run_id: str) -> tuple[InteractionRecord, ...]: + with self.database.transaction() as connection: + rows = connection.execute( + "SELECT * FROM agent_interactions WHERE run_id = ? ORDER BY created_at", + (run_id,), + ).fetchall() + return tuple(self._interaction(row) for row in rows) + + def snapshot(self, run_id: str): + run = self.get_run(run_id) + definition = self.get_definition(run.agent_definition_id) + return ( + definition, + run, + self.get_status_line(run_id), + self.list_steps(run_id), + self.list_interactions(run_id), + ) + + def _status_line_in_transaction( + self, + connection, + run_row, + *, + phase: str, + text: str, + presentation: str, + progress: float | None = None, + content: dict[str, Any] | None = None, + ) -> StatusLineRecord: + now = utc_now_text() + connection.execute( + """ + UPDATE agent_status_lines + SET phase = ?, text = ?, presentation = ?, progress = ?, + content_json = ?, revision = revision + 1, updated_at = ? + WHERE run_id = ? AND status_key = 'primary' + """, + ( + phase, + text, + presentation, + progress, + _json(content or {}), + now, + run_row["id"], + ), + ) + row = connection.execute( + "SELECT * FROM agent_status_lines WHERE run_id = ? AND status_key = 'primary'", + (run_row["id"],), + ).fetchone() + assert row is not None + status = self._status(row) + session = connection.execute( + "SELECT app_instance_id FROM sessions WHERE id = ?", + (run_row["session_id"],), + ).fetchone() + assert session is not None + self.events.append_in_transaction( + connection, + event_type="agent.status", + subject_id=run_row["id"], + app_instance_id=session["app_instance_id"], + session_id=run_row["session_id"], + payload={ + "run_id": run_row["id"], + "status_id": "primary", + "phase": phase, + "text": text, + "presentation": presentation, + "progress": progress, + "content": content or {}, + "revision": status.revision, + }, + ) + return status + + def update_status_line( + self, + run_id: str, + *, + phase: str, + text: str, + presentation: str = "plain", + progress: float | None = None, + content: dict[str, Any] | None = None, + ) -> StatusLineRecord: + with self.database.transaction(write=True) as connection: + run = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (run_id,) + ).fetchone() + if run is None: + raise ResourceNotFoundError("agent_run", run_id) + return self._status_line_in_transaction( + connection, + run, + phase=phase, + text=text, + presentation=presentation, + progress=progress, + content=content, + ) + + def transition( + self, + run_id: str, + *, + expected: set[AgentRunStatus], + status: AgentRunStatus, + output: dict[str, Any] | None = None, + error: dict[str, Any] | None = None, + status_text: str | None = None, + presentation: str | None = None, + ) -> AgentRunRecord: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + row = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (run_id,) + ).fetchone() + if row is None: + raise ResourceNotFoundError("agent_run", run_id) + current = AgentRunStatus(row["status"]) + if current not in expected: + raise ResourceConflictError( + f"AgentRun {run_id} is {current.value}, expected " + + ", ".join(sorted(item.value for item in expected)) + ) + finished = status in { + AgentRunStatus.COMPLETED, + AgentRunStatus.FAILED, + AgentRunStatus.CANCELLED, + } + connection.execute( + """ + UPDATE agent_runs + SET status = ?, output_json = COALESCE(?, output_json), + error_json = COALESCE(?, error_json), + started_at = CASE + WHEN ? = 'running' THEN COALESCE(started_at, ?) + ELSE started_at END, + finished_at = CASE WHEN ? THEN ? ELSE finished_at END, + revision = revision + 1, updated_at = ? + WHERE id = ? + """, + ( + status.value, + None if output is None else _json(output), + None if error is None else _json(error), + status.value, + now, + int(finished), + now, + now, + run_id, + ), + ) + updated = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (run_id,) + ).fetchone() + assert updated is not None + defaults = { + AgentRunStatus.QUEUED: ("Queued", "pulse"), + AgentRunStatus.PLANNING: ("Planning…", "pulse"), + AgentRunStatus.RUNNING: ("Running…", "pulse"), + AgentRunStatus.WAITING_INPUT: ("Waiting for your input", "warning"), + AgentRunStatus.WAITING_CAPABILITY: ("Waiting for approval", "warning"), + AgentRunStatus.INTERRUPTED: ("Interrupted; recovery needed", "warning"), + AgentRunStatus.COMPLETED: ("Completed", "plain"), + AgentRunStatus.FAILED: ("Failed", "error"), + AgentRunStatus.CANCELLED: ("Cancelled", "plain"), + } + default_text, default_presentation = defaults[status] + self._status_line_in_transaction( + connection, + updated, + phase=status.value, + text=status_text or default_text, + presentation=presentation or default_presentation, + ) + session = connection.execute( + "SELECT app_instance_id FROM sessions WHERE id = ?", + (updated["session_id"],), + ).fetchone() + assert session is not None + self.events.append_in_transaction( + connection, + event_type=f"agent.run.{status.value}", + subject_id=run_id, + app_instance_id=session["app_instance_id"], + session_id=updated["session_id"], + payload={"run_id": run_id, "status": status.value}, + ) + return self._run(updated) + + def claim_next(self) -> AgentRunRecord | None: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + expired = connection.execute( + """ + SELECT id FROM agent_runs + WHERE status = 'queued' AND deadline_at <= ? + """, + (now,), + ).fetchall() + for item in expired: + connection.execute( + """ + UPDATE agent_runs SET status = 'cancelled', + error_json = ?, finished_at = ?, updated_at = ?, + revision = revision + 1 WHERE id = ? + """, + (_json({"code": "run_deadline_exceeded"}), now, now, item["id"]), + ) + expired_run = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (item["id"],) + ).fetchone() + assert expired_run is not None + self._status_line_in_transaction( + connection, + expired_run, + phase="cancelled", + text="Run deadline exceeded", + presentation="error", + ) + candidates = connection.execute( + """ + SELECT r.*, d.concurrency_group, g.concurrency_limit + FROM agent_runs r + JOIN agent_definitions d ON d.id = r.agent_definition_id + LEFT JOIN agent_concurrency_groups g + ON g.group_key = d.concurrency_group + WHERE r.status = 'queued' AND r.cancel_requested = 0 + AND r.deadline_at > ? AND d.status = 'enabled' + ORDER BY r.priority DESC, r.created_at, r.id + """, + (now,), + ).fetchall() + selected = None + for candidate in candidates: + group = candidate["concurrency_group"] + if group is None: + selected = candidate + break + active = connection.execute( + """ + SELECT COUNT(*) FROM agent_runs r + JOIN agent_definitions d ON d.id = r.agent_definition_id + WHERE d.concurrency_group = ? + AND r.status IN ('planning', 'running') + """, + (group,), + ).fetchone()[0] + if active < candidate["concurrency_limit"]: + selected = candidate + break + if selected is None: + return None + connection.execute( + """ + UPDATE agent_runs SET status = 'planning', + revision = revision + 1, updated_at = ? + WHERE id = ? AND status = 'queued' + """, + (now, selected["id"]), + ) + row = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (selected["id"],) + ).fetchone() + assert row is not None + self._status_line_in_transaction( + connection, + row, + phase="planning", + text="Planning…", + presentation="pulse", + ) + session = connection.execute( + "SELECT app_instance_id FROM sessions WHERE id = ?", + (row["session_id"],), + ).fetchone() + assert session is not None + self.events.append_in_transaction( + connection, + event_type="agent.run.planning", + subject_id=row["id"], + app_instance_id=session["app_instance_id"], + session_id=row["session_id"], + payload={"run_id": row["id"], "status": "planning"}, + ) + return self._run(row) + + def dispatching_count(self) -> int: + with self.database.transaction() as connection: + return int( + connection.execute( + """ + SELECT COUNT(*) FROM agent_runs + WHERE status IN ('queued', 'planning', 'running') + """ + ).fetchone()[0] + ) + + def suspend_queued_for_shutdown(self) -> int: + """Mark queued Runs so their deadline excludes time spent offline.""" + + now = utc_now_text() + with self.database.transaction(write=True) as connection: + cursor = connection.execute( + """ + UPDATE agent_runs SET error_json = ?, updated_at = ?, + revision = revision + 1 + WHERE status = 'queued' AND cancel_requested = 0 + AND (error_json IS NULL OR json_extract(error_json, '$.code') + != 'runtime_stopped') + """, + (_json({"code": "runtime_stopped"}), now), + ) + return int(cursor.rowcount) + + def create_step( + self, + run_id: str, + *, + action_key: str, + kind: str, + input: dict[str, Any], + tool_name: str | None = None, + ) -> tuple[RunStepRecord, bool]: + now = utc_now_text() + step_id = new_entity_id(EntityIdKind.RUN_STEP) + with self.database.transaction(write=True) as connection: + existing = connection.execute( + "SELECT * FROM run_steps WHERE run_id = ? AND action_key = ?", + (run_id, action_key), + ).fetchone() + if existing is not None: + return self._step(existing), False + run = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (run_id,) + ).fetchone() + if run is None: + raise ResourceNotFoundError("agent_run", run_id) + sequence = run["current_step"] + 1 + connection.execute( + """ + INSERT INTO run_steps( + id, run_id, sequence, action_key, kind, status, + tool_name, input_json, created_at, started_at + ) VALUES (?, ?, ?, ?, ?, 'running', ?, ?, ?, ?) + """, + ( + step_id, + run_id, + sequence, + action_key, + kind, + tool_name, + _json(input), + now, + now, + ), + ) + connection.execute( + """ + UPDATE agent_runs SET current_step = ?, revision = revision + 1, + updated_at = ? WHERE id = ? + """, + (sequence, now, run_id), + ) + row = connection.execute( + "SELECT * FROM run_steps WHERE id = ?", (step_id,) + ).fetchone() + assert row is not None + return self._step(row), True + + def settle_step( + self, + step_id: str, + *, + status: RunStepStatus, + output: dict[str, Any] | None = None, + error: dict[str, Any] | None = None, + ) -> RunStepRecord: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + cursor = connection.execute( + """ + UPDATE run_steps SET status = ?, output_json = ?, error_json = ?, + finished_at = ? WHERE id = ? AND status = 'running' + """, + ( + status.value, + None if output is None else _json(output), + None if error is None else _json(error), + now, + step_id, + ), + ) + if cursor.rowcount == 0: + row = connection.execute( + "SELECT * FROM run_steps WHERE id = ?", (step_id,) + ).fetchone() + if row is None: + raise ResourceNotFoundError("run_step", step_id) + return self._step(row) + row = connection.execute( + "SELECT * FROM run_steps WHERE id = ?", (step_id,) + ).fetchone() + assert row is not None + return self._step(row) + + def abandon_step_for_retry( + self, + step_id: str, + *, + error: dict[str, Any], + ) -> RunStepRecord: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + cursor = connection.execute( + """ + UPDATE run_steps SET status = 'cancelled', + action_key = action_key || ':retry:' || id, + error_json = ?, finished_at = ? + WHERE id = ? AND status = 'running' + """, + (_json(error), now, step_id), + ) + if cursor.rowcount == 0: + raise ResourceConflictError("RunStep is not running") + row = connection.execute( + "SELECT * FROM run_steps WHERE id = ?", (step_id,) + ).fetchone() + assert row is not None + return self._step(row) + + def create_interaction( + self, + run_id: str, + *, + request_key: str, + kind: InteractionKind, + prompt: str, + response_schema: dict[str, Any], + ui_hints: dict[str, Any] | None = None, + request: dict[str, Any] | None = None, + timeout_seconds: int = 86_400, + ) -> InteractionRecord: + Draft202012Validator.check_schema(response_schema) + now_dt = datetime.now(UTC) + now = format_utc(now_dt) + deadline = format_utc(now_dt + timedelta(seconds=timeout_seconds)) + interaction_id = new_entity_id(EntityIdKind.INTERACTION) + with self.database.transaction(write=True) as connection: + existing = connection.execute( + """ + SELECT * FROM agent_interactions + WHERE run_id = ? AND request_key = ? + """, + (run_id, request_key), + ).fetchone() + if existing is not None: + return self._interaction(existing) + run = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (run_id,) + ).fetchone() + if run is None: + raise ResourceNotFoundError("agent_run", run_id) + waiting = ( + AgentRunStatus.WAITING_CAPABILITY + if kind is InteractionKind.APPROVAL + else AgentRunStatus.WAITING_INPUT + ) + connection.execute( + """ + INSERT INTO agent_interactions( + id, run_id, request_key, kind, prompt, response_schema_json, + ui_hints_json, request_json, deadline_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + interaction_id, + run_id, + request_key, + kind.value, + prompt, + _json(response_schema), + _json(ui_hints or {}), + _json(request or {}), + deadline, + now, + now, + ), + ) + connection.execute( + """ + UPDATE agent_runs SET status = ?, revision = revision + 1, + updated_at = ? WHERE id = ? AND status = 'running' + """, + (waiting.value, now, run_id), + ) + updated = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (run_id,) + ).fetchone() + assert updated is not None + self._status_line_in_transaction( + connection, + updated, + phase=waiting.value, + text=prompt, + presentation="warning", + ) + session = connection.execute( + "SELECT app_instance_id FROM sessions WHERE id = ?", + (updated["session_id"],), + ).fetchone() + assert session is not None + self.events.append_in_transaction( + connection, + event_type=( + "agent.approval.request" + if kind is InteractionKind.APPROVAL + else "agent.input.request" + ), + subject_id=run_id, + app_instance_id=session["app_instance_id"], + session_id=updated["session_id"], + payload={ + "run_id": run_id, + "interaction_id": interaction_id, + "kind": kind.value, + "prompt": prompt, + "response_schema": response_schema, + "ui_hints": ui_hints or {}, + "request": request or {}, + "deadline_at": deadline, + }, + ) + row = connection.execute( + "SELECT * FROM agent_interactions WHERE id = ?", + (interaction_id,), + ).fetchone() + assert row is not None + return self._interaction(row) + + def respond_interaction( + self, + run_id: str, + interaction_id: str, + *, + response: dict[str, Any], + response_id: str, + ) -> InteractionRecord: + now_dt = datetime.now(UTC) + now = format_utc(now_dt) + with self.database.transaction(write=True) as connection: + row = connection.execute( + """ + SELECT * FROM agent_interactions + WHERE id = ? AND run_id = ? + """, + (interaction_id, run_id), + ).fetchone() + if row is None: + raise ResourceNotFoundError("agent_interaction", interaction_id) + if row["status"] != "pending": + if row["response_id"] == response_id and row["response_json"] == _json( + response + ): + return self._interaction(row) + raise ResourceConflictError("Interaction has already been resolved") + if row["deadline_at"] <= now: + raise ResourceConflictError("Interaction deadline has expired") + try: + Draft202012Validator(json.loads(row["response_schema_json"])).validate( + response + ) + except ValidationError as exc: + raise ResourceConflictError( + f"Interaction response is invalid: {exc.message}" + ) from exc + kind = InteractionKind(row["kind"]) + if kind is InteractionKind.FILE: + handle_values = [ + value + for value in response.values() + if isinstance(value, str) and value.startswith("resource://") + ] + if len(handle_values) != 1: + raise ResourceConflictError( + "File interaction must return one ResourceHandle URI" + ) + handle_id = handle_values[0].removeprefix("resource://") + run_session = connection.execute( + "SELECT session_id FROM agent_runs WHERE id = ?", (run_id,) + ).fetchone() + assert run_session is not None + handle = connection.execute( + """SELECT id FROM resource_handles + WHERE id = ? AND session_id = ? AND revoked_at IS NULL + AND kind IN ('file', 'artifact') + AND EXISTS ( + SELECT 1 FROM json_each(capabilities_json) + WHERE value = 'read' + ) + AND (expires_at IS NULL OR expires_at > ?)""", + (handle_id, run_session["session_id"], now), + ).fetchone() + if handle is None: + raise ResourceConflictError( + "ResourceHandle is unavailable in this Session" + ) + decision = ( + response.get("decision") if kind is InteractionKind.APPROVAL else None + ) + new_status = ( + InteractionStatus.APPROVED + if decision == "approve" + else InteractionStatus.DENIED + if decision == "deny" + else InteractionStatus.SUBMITTED + ) + connection.execute( + """ + UPDATE agent_interactions + SET status = ?, response_json = ?, response_id = ?, resolved_at = ?, + revision = revision + 1, updated_at = ? + WHERE id = ? + """, + ( + new_status.value, + _json(response), + response_id, + now, + now, + interaction_id, + ), + ) + run = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (run_id,) + ).fetchone() + assert run is not None + resumed_deadline = _resume_deadline( + run["deadline_at"], run["updated_at"], now_dt + ) + session = connection.execute( + "SELECT app_instance_id FROM sessions WHERE id = ?", + (run["session_id"],), + ).fetchone() + assert session is not None + if new_status is InteractionStatus.DENIED: + connection.execute( + """ + UPDATE agent_runs SET status = 'failed', error_json = ?, + finished_at = ?, revision = revision + 1, updated_at = ? + WHERE id = ? + """, + ( + _json( + { + "code": "approval_denied", + "interaction_id": interaction_id, + } + ), + now, + now, + run_id, + ), + ) + next_phase = "failed" + status_text = "Approval denied" + presentation = "error" + else: + grants = set(json.loads(run["granted_capabilities_json"])) + if new_status is InteractionStatus.APPROVED: + approval_request = json.loads(row["request_json"]) + approved = tuple(approval_request.get("capabilities", [])) + grants.update(approved) + requested_scope = response.get("scope", "once") + single_use = requested_scope == "once" + scope = GrantScope.RUN if single_use else GrantScope(requested_scope) + scope_id = { + GrantScope.RUN: run_id, + GrantScope.SESSION: run["session_id"], + GrantScope.AGENT: run["agent_definition_id"], + GrantScope.APP: session["app_instance_id"], + }[scope] + lease_id = new_entity_id(EntityIdKind.GRANT_LEASE) + expires_at = resumed_deadline if scope is GrantScope.RUN else None + evidence = { + "interaction_id": interaction_id, + "response_id": response_id, + "decision": "approve", + "scope": scope.value, + "requested_scope": requested_scope, + "single_use": single_use, + } + tool_name = approval_request.get("tool_name", "*") + tool_row = connection.execute( + """SELECT s.active_package_digest FROM tool_descriptors t + JOIN service_descriptors s ON s.id = t.service_id + WHERE t.qualified_name = ?""", + (tool_name,), + ).fetchone() + tool_service_digest = None if tool_row is None else tool_row[0] + connection.execute( + """INSERT INTO grant_leases( + id, scope, scope_id, agent_definition_id, session_id, + app_instance_id, capabilities_json, tool_pattern, + tool_service_digest, + resource_selector_json, issued_by, evidence_json, + expires_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'user', ?, ?, ?, ?)""", + ( + lease_id, + scope.value, + scope_id, + run["agent_definition_id"], + run["session_id"], + session["app_instance_id"], + _json(sorted(set(approved))), + tool_name, + tool_service_digest, + _json(approval_request.get("resource_selector", {})), + _json(evidence), + expires_at, + now, + now, + ), + ) + self.events.append_in_transaction( + connection, + event_type="capability.grant.created", + subject_id=lease_id, + app_instance_id=session["app_instance_id"], + session_id=run["session_id"], + trace_id=run_id, + payload={ + "run_id": run_id, + "scope": scope.value, + "capabilities": sorted(set(approved)), + "tool_pattern": approval_request.get("tool_name", "*"), + "issued_by": "user", + "evidence": evidence, + }, + ) + connection.execute( + """ + UPDATE agent_runs SET status = 'queued', + granted_capabilities_json = ?, revision = revision + 1, + deadline_at = ?, updated_at = ? WHERE id = ? + """, + (_json(sorted(grants)), resumed_deadline, now, run_id), + ) + next_phase = "queued" + status_text = "Input received; queued to resume" + presentation = "pulse" + updated_run = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (run_id,) + ).fetchone() + assert updated_run is not None + self._status_line_in_transaction( + connection, + updated_run, + phase=next_phase, + text=status_text, + presentation=presentation, + ) + self.events.append_in_transaction( + connection, + event_type=f"agent.interaction.{new_status.value}", + subject_id=run_id, + app_instance_id=session["app_instance_id"], + session_id=run["session_id"], + payload={ + "run_id": run_id, + "interaction_id": interaction_id, + "status": new_status.value, + "response": response, + }, + ) + if kind is InteractionKind.APPROVAL: + approval_request = json.loads(row["request_json"]) + decision_id = new_entity_id(EntityIdKind.CAPABILITY_DECISION) + decision_value = ( + "allow" if new_status is InteractionStatus.APPROVED else "deny" + ) + evidence = { + "interaction_id": interaction_id, + "response_id": response_id, + "response": response, + } + connection.execute( + """INSERT INTO capability_decisions( + id, run_id, interaction_id, decision, decision_source, + capabilities_json, tool_name, effects_json, + matched_policy_ids_json, evidence_json, created_at + ) VALUES (?, ?, ?, ?, 'user', ?, ?, ?, '[]', ?, ?)""", + ( + decision_id, + run_id, + interaction_id, + decision_value, + _json(approval_request.get("capabilities", [])), + approval_request.get("tool_name", "*"), + _json(approval_request.get("effects", [])), + _json(evidence), + now, + ), + ) + self.events.append_in_transaction( + connection, + event_type=f"capability.decision.{decision_value}", + subject_id=run_id, + app_instance_id=session["app_instance_id"], + session_id=run["session_id"], + trace_id=run_id, + payload={ + "decision_id": decision_id, + "source": "user", + "capabilities": approval_request.get("capabilities", []), + "tool_name": approval_request.get("tool_name", "*"), + "evidence": evidence, + }, + ) + resolved = connection.execute( + "SELECT * FROM agent_interactions WHERE id = ?", + (interaction_id,), + ).fetchone() + assert resolved is not None + return self._interaction(resolved) + + def request_pause(self, run_id: str) -> AgentRunRecord: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + row = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (run_id,) + ).fetchone() + if row is None: + raise ResourceNotFoundError("agent_run", run_id) + if row["status"] == "interrupted": + return self._run(row) + if row["status"] not in ("queued", "planning", "running"): + raise ResourceConflictError( + "Only a queued, planning, or running AgentRun can pause" + ) + connection.execute( + """ + UPDATE agent_runs SET status = 'interrupted', error_json = ?, + revision = revision + 1, updated_at = ? WHERE id = ? + """, + (_json({"code": "user_paused"}), now, run_id), + ) + updated = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (run_id,) + ).fetchone() + assert updated is not None + self._status_line_in_transaction( + connection, + updated, + phase="interrupted", + text="Paused", + presentation="warning", + ) + session = connection.execute( + "SELECT app_instance_id FROM sessions WHERE id = ?", + (updated["session_id"],), + ).fetchone() + assert session is not None + self.events.append_in_transaction( + connection, + event_type="agent.run.paused", + subject_id=run_id, + app_instance_id=session["app_instance_id"], + session_id=updated["session_id"], + payload={"run_id": run_id, "status": "interrupted"}, + ) + return self._run(updated) + + def request_cancel(self, run_id: str) -> AgentRunRecord: + now = utc_now_text() + with self.database.transaction(write=True) as connection: + row = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (run_id,) + ).fetchone() + if row is None: + raise ResourceNotFoundError("agent_run", run_id) + if row["status"] in ("completed", "failed", "cancelled"): + return self._run(row) + connection.execute( + """ + UPDATE agent_runs SET status = 'cancelled', cancel_requested = 1, + finished_at = ?, revision = revision + 1, updated_at = ? + WHERE id = ? + """, + (now, now, run_id), + ) + connection.execute( + """ + UPDATE agent_interactions SET status = 'cancelled', resolved_at = ?, + revision = revision + 1, updated_at = ? + WHERE run_id = ? AND status = 'pending' + """, + (now, now, run_id), + ) + updated = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (run_id,) + ).fetchone() + assert updated is not None + self._status_line_in_transaction( + connection, + updated, + phase="cancelled", + text="Cancelled", + presentation="plain", + ) + session = connection.execute( + "SELECT app_instance_id FROM sessions WHERE id = ?", + (updated["session_id"],), + ).fetchone() + assert session is not None + self.events.append_in_transaction( + connection, + event_type="agent.run.cancelled", + subject_id=run_id, + app_instance_id=session["app_instance_id"], + session_id=updated["session_id"], + payload={"run_id": run_id, "status": "cancelled"}, + ) + return self._run(updated) + + def resume_interrupted( + self, + run_id: str, + *, + uncertain_resolution: str | None = None, + ) -> AgentRunRecord: + if uncertain_resolution not in {None, "retry", "assume_completed"}: + raise ValueError("uncertain_resolution must be retry or assume_completed") + now_dt = datetime.now(UTC) + now = format_utc(now_dt) + with self.database.transaction(write=True) as connection: + run = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (run_id,) + ).fetchone() + if run is None: + raise ResourceNotFoundError("agent_run", run_id) + if run["status"] != "interrupted": + raise ResourceConflictError("Only an interrupted AgentRun can resume") + resumed_deadline = _resume_deadline( + run["deadline_at"], run["updated_at"], now_dt + ) + uncertain = connection.execute( + "SELECT * FROM run_steps WHERE run_id = ? AND status = 'uncertain'", + (run_id,), + ).fetchall() + if uncertain and uncertain_resolution is None: + raise ResourceConflictError( + "Uncertain Tool effects require retry or assume_completed" + ) + for step in uncertain: + if uncertain_resolution == "retry": + connection.execute( + """ + UPDATE run_steps SET status = 'failed', + action_key = action_key || ':uncertain:' || id, + error_json = ?, finished_at = ? WHERE id = ? + """, + ( + _json({"code": "uncertain_effect_retry_authorized"}), + now, + step["id"], + ), + ) + else: + connection.execute( + """ + UPDATE run_steps SET status = 'completed', output_json = ?, + finished_at = ? WHERE id = ? + """, + ( + _json({"assumed_completed": True, "user_resolved": True}), + now, + step["id"], + ), + ) + connection.execute( + """ + UPDATE agent_runs SET status = 'queued', error_json = NULL, + cancel_requested = 0, deadline_at = ?, + revision = revision + 1, updated_at = ? + WHERE id = ? + """, + (resumed_deadline, now, run_id), + ) + updated = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (run_id,) + ).fetchone() + assert updated is not None + self._status_line_in_transaction( + connection, + updated, + phase="queued", + text="Queued to resume", + presentation="pulse", + ) + return self._run(updated) + + def recover_interrupted(self) -> dict[str, int]: + recovered = interrupted = failed = 0 + now_dt = datetime.now(UTC) + now = format_utc(now_dt) + with self.database.transaction(write=True) as connection: + suspended = connection.execute( + """ + SELECT * FROM agent_runs + WHERE status = 'queued' + AND json_extract(error_json, '$.code') = 'runtime_stopped' + """ + ).fetchall() + for run in suspended: + connection.execute( + """ + UPDATE agent_runs SET deadline_at = ?, error_json = NULL, + revision = revision + 1, updated_at = ? WHERE id = ? + """, + ( + _resume_deadline( + run["deadline_at"], run["updated_at"], now_dt + ), + now, + run["id"], + ), + ) + recovered += 1 + rows = connection.execute( + """ + SELECT r.*, d.resume_policy FROM agent_runs r + JOIN agent_definitions d ON d.id = r.agent_definition_id + WHERE r.status IN ('planning', 'running') + """ + ).fetchall() + for run in rows: + uncertain = connection.execute( + """ + SELECT id FROM run_steps + WHERE run_id = ? AND kind = 'tool' AND status = 'running' + """, + (run["id"],), + ).fetchall() + if uncertain: + connection.execute( + "UPDATE run_steps SET status = 'uncertain' WHERE run_id = ? AND status = 'running'", + (run["id"],), + ) + target = "interrupted" + error = {"code": "uncertain_tool_side_effect"} + interrupted += 1 + elif run["resume_policy"] == "restart": + target = "queued" + error = None + recovered += 1 + else: + target = "failed" + error = {"code": "run_interrupted"} + failed += 1 + recovered_deadline = ( + run["deadline_at"] + if target == "failed" + else _resume_deadline(run["deadline_at"], run["updated_at"], now_dt) + ) + connection.execute( + """ + UPDATE agent_runs SET status = ?, error_json = ?, deadline_at = ?, + finished_at = CASE WHEN ? = 'failed' THEN ? ELSE NULL END, + revision = revision + 1, updated_at = ? WHERE id = ? + """, + ( + target, + None if error is None else _json(error), + recovered_deadline, + target, + now, + now, + run["id"], + ), + ) + updated = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", (run["id"],) + ).fetchone() + assert updated is not None + status_text = { + "queued": "Recovered and queued", + "interrupted": "Interrupted during a Tool; choose recovery", + "failed": "Failed after server interruption", + }[target] + self._status_line_in_transaction( + connection, + updated, + phase=target, + text=status_text, + presentation="warning" if target != "failed" else "error", + ) + return {"recovered": recovered, "interrupted": interrupted, "failed": failed} + + def expire_interactions(self) -> int: + now = utc_now_text() + count = 0 + with self.database.transaction(write=True) as connection: + rows = connection.execute( + """ + SELECT * FROM agent_interactions + WHERE status = 'pending' AND deadline_at <= ? + """, + (now,), + ).fetchall() + for interaction in rows: + connection.execute( + """ + UPDATE agent_interactions SET status = 'expired', resolved_at = ?, + revision = revision + 1, updated_at = ? WHERE id = ? + """, + (now, now, interaction["id"]), + ) + connection.execute( + """ + UPDATE agent_runs SET status = 'failed', error_json = ?, + finished_at = ?, revision = revision + 1, updated_at = ? + WHERE id = ? AND status IN ('waiting_input', 'waiting_capability') + """, + ( + _json( + { + "code": "interaction_expired", + "interaction_id": interaction["id"], + } + ), + now, + now, + interaction["run_id"], + ), + ) + run = connection.execute( + "SELECT * FROM agent_runs WHERE id = ?", + (interaction["run_id"],), + ).fetchone() + assert run is not None + if run["status"] == "failed": + self._status_line_in_transaction( + connection, + run, + phase="failed", + text="Interaction expired", + presentation="error", + ) + session = connection.execute( + "SELECT app_instance_id FROM sessions WHERE id = ?", + (run["session_id"],), + ).fetchone() + assert session is not None + self.events.append_in_transaction( + connection, + event_type="agent.interaction.expired", + subject_id=run["id"], + app_instance_id=session["app_instance_id"], + session_id=run["session_id"], + payload={ + "run_id": run["id"], + "interaction_id": interaction["id"], + }, + ) + count += 1 + return count diff --git a/ai2apps/agents/runtime.py b/ai2apps/agents/runtime.py new file mode 100644 index 00000000..763d7262 --- /dev/null +++ b/ai2apps/agents/runtime.py @@ -0,0 +1,1015 @@ +"""Recoverable asynchronous Agent scheduler and action interpreter.""" + +from __future__ import annotations + +import asyncio +import inspect +import logging +from collections.abc import Awaitable, Callable +from contextlib import suppress +from datetime import UTC, datetime + +from ai2apps.capabilities import ( + CapabilityPolicyEngine, + CapabilityRepository, + PolicyEffect, + action_preview, +) +from ai2apps.services import ToolCallContext, ToolGateway, ToolGatewayError + +from .models import ( + AgentAction, + AgentExecutionContext, + AgentRunStatus, + CompleteAction, + ContinueAction, + FailAction, + InteractionAction, + InteractionKind, + InteractionStatus, + ModelCallAction, + RunStepStatus, + StatusAction, + ToolCallAction, +) +from .repository import AgentRepository + +logger = logging.getLogger(__name__) + +AgentExecutor = Callable[ + [AgentExecutionContext], + AgentAction | Awaitable[AgentAction], +] +ModelProgressReporter = Callable[[dict], None | Awaitable[None]] +ModelProvider = Callable[..., dict | Awaitable[dict]] + + +async def _await_action(value): + if inspect.isawaitable(value): + return await value + return value + + +class AgentRuntime: + def __init__( + self, + repository: AgentRepository, + tools: ToolGateway, + capability_policy: CapabilityPolicyEngine | None = None, + capabilities: CapabilityRepository | None = None, + *, + global_concurrency: int = 32, + ) -> None: + if global_concurrency <= 0: + raise ValueError("global_concurrency must be positive") + self.repository = repository + self.tools = tools + self.capability_policy = capability_policy + self.capabilities = capabilities + self.global_concurrency = global_concurrency + self._executors: dict[str, AgentExecutor] = {} + self._model_provider: ModelProvider | None = None + self._model_provider_accepts_progress = False + self._wake = asyncio.Event() + self._stopping = False + self._dispatcher: asyncio.Task[None] | None = None + self._tasks: dict[str, asyncio.Task[None]] = {} + self._run_terminal_handlers: list[Callable[[str], None]] = [] + self._terminal_events: dict[str, asyncio.Event] = {} + + def bind_executor(self, executor_key: str, executor: AgentExecutor) -> None: + if not executor_key: + raise ValueError("executor_key must not be empty") + self._executors[executor_key] = executor + + def bind_model_provider(self, provider: ModelProvider) -> None: + self._model_provider = provider + try: + parameters = inspect.signature(provider).parameters.values() + self._model_provider_accepts_progress = any( + item.kind is inspect.Parameter.VAR_POSITIONAL for item in parameters + ) or sum( + item.kind + in { + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + } + for item in parameters + ) >= 2 + except (TypeError, ValueError): + self._model_provider_accepts_progress = False + + async def _call_model_provider( + self, + request: dict, + progress_reporter: ModelProgressReporter, + ) -> dict: + assert self._model_provider is not None + if self._model_provider_accepts_progress: + value = self._model_provider(request, progress_reporter) + else: + value = self._model_provider(request) + return await _await_action(value) + + async def _run_model_call( + self, + run_id: str, + request: dict, + *, + model_name: str, + step_sequence: int, + ) -> dict: + """Invoke a model while durably surfacing stream and wait progress.""" + + latest_update: dict = {} + started = asyncio.get_running_loop().time() + + async def publish(update: dict) -> None: + nonlocal latest_update + latest_update = dict(update) + content = dict(update.get("content") or {}) + content.setdefault("tone", "info") + content.setdefault("icon", "sparkles") + content.setdefault("model", model_name) + content.setdefault("step", step_sequence) + await asyncio.to_thread( + self.repository.update_status_line, + run_id, + phase=str(update.get("phase") or "model"), + text=str(update.get("text") or f"Waiting for {model_name}"), + presentation=str(update.get("presentation") or "indeterminate"), + progress=update.get("progress"), + content=content, + ) + + await publish( + { + "phase": "model_starting", + "text": f"Starting {model_name}", + "presentation": "indeterminate", + "content": { + "effect": "indeterminate", + "detail": f"Preparing model context · step {step_sequence}", + }, + } + ) + task = asyncio.create_task( + self._call_model_provider(request, publish), + name=f"ai2apps-agent-model-{run_id}-{step_sequence}", + ) + try: + while True: + done, _ = await asyncio.wait({task}, timeout=5.0) + if task in done: + return await task + elapsed = max(1, int(asyncio.get_running_loop().time() - started)) + content = dict(latest_update.get("content") or {}) + detail = content.get("detail") + if latest_update.get("phase") == "model_streaming": + text = str(latest_update.get("text") or "Receiving model response") + if detail: + detail = f"{detail} · {elapsed}s elapsed" + else: + text = f"Waiting for {model_name} · {elapsed}s" + detail = "The model is generating the next Agent action" + await publish( + { + "phase": str(latest_update.get("phase") or "model_waiting"), + "text": text, + "presentation": "indeterminate", + "content": { + **content, + "effect": "indeterminate", + "detail": detail, + "elapsed_seconds": elapsed, + }, + } + ) + finally: + if not task.done(): + task.cancel() + with suppress(asyncio.CancelledError): + await task + + def bind_run_terminal_handler(self, handler: Callable[[str], None]) -> None: + """Register fail-safe cleanup invoked when a Run reaches a terminal state.""" + + self._run_terminal_handlers.append(handler) + + def _notify_run_terminal(self, run_id: str) -> None: + event = self._terminal_events.pop(run_id, None) + if event is not None: + event.set() + for handler in tuple(self._run_terminal_handlers): + try: + handler(run_id) + except Exception: + logger.exception("Agent Run terminal cleanup failed for %s", run_id) + + async def start(self) -> None: + if self._dispatcher is not None: + return + self._stopping = False + await asyncio.to_thread(self.repository.recover_interrupted) + self._dispatcher = asyncio.create_task( + self._dispatch_loop(), + name="ai2apps-agent-dispatcher", + ) + self.wake() + + async def stop(self) -> None: + self._stopping = True + if self._dispatcher is not None: + self._dispatcher.cancel() + with suppress(asyncio.CancelledError): + await self._dispatcher + self._dispatcher = None + await asyncio.to_thread(self.repository.suspend_queued_for_shutdown) + tasks = tuple(self._tasks.values()) + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + self._tasks.clear() + + def wake(self) -> None: + self._wake.set() + + def cancel(self, run_id: str): + descendants = self.repository.list_descendants(run_id) + for child in reversed(descendants): + if child.status not in { + AgentRunStatus.COMPLETED, + AgentRunStatus.FAILED, + AgentRunStatus.CANCELLED, + }: + self.repository.request_cancel(child.id) + self._notify_run_terminal(child.id) + task = self._tasks.get(child.id) + if task is not None: + task.cancel() + run = self.repository.request_cancel(run_id) + self._notify_run_terminal(run_id) + task = self._tasks.get(run_id) + if task is not None: + task.cancel() + self.wake() + return run + + def pause(self, run_id: str): + descendants = self.repository.list_descendants(run_id) + for child in reversed(descendants): + if child.status in { + AgentRunStatus.QUEUED, + AgentRunStatus.PLANNING, + AgentRunStatus.RUNNING, + }: + self.repository.request_pause(child.id) + task = self._tasks.get(child.id) + if task is not None: + task.cancel() + run = self.repository.request_pause(run_id) + task = self._tasks.get(run_id) + if task is not None: + task.cancel() + self.wake() + return run + + def resume(self, run_id: str, *, uncertain_resolution: str | None = None): + """Resume a parent plus safely paused descendants, deepest first.""" + + for child in reversed(self.repository.list_descendants(run_id)): + if child.status is not AgentRunStatus.INTERRUPTED: + continue + has_uncertain = any( + step.status is RunStepStatus.UNCERTAIN + for step in self.repository.list_steps(child.id) + ) + if not has_uncertain: + self.repository.resume_interrupted(child.id) + run = self.repository.resume_interrupted( + run_id, uncertain_resolution=uncertain_resolution + ) + self.wake() + return run + + async def wait_for_terminal( + self, run_id: str, *, timeout: float | None = None + ): + """Wait for a child Run without losing its durable terminal result.""" + + terminal = { + AgentRunStatus.COMPLETED, + AgentRunStatus.FAILED, + AgentRunStatus.CANCELLED, + } + run = await asyncio.to_thread(self.repository.get_run, run_id) + if run.status in terminal: + return run + event = self._terminal_events.setdefault(run_id, asyncio.Event()) + # Close the registration race with a second durable read. + run = await asyncio.to_thread(self.repository.get_run, run_id) + if run.status not in terminal: + await asyncio.wait_for(event.wait(), timeout=timeout) + return await asyncio.to_thread(self.repository.get_run, run_id) + + @staticmethod + def _step_budget(run, definition) -> int: + configured = run.input.get("run_budget", {}).get("max_steps") + limit = ( + min(definition.max_steps, configured) + if isinstance(configured, int) and configured > 0 + else definition.max_steps + ) + delegated = run.delegation.get("budget", {}).get("max_steps") + if isinstance(delegated, int) and delegated > 0: + return min(limit, delegated) + return limit + + async def wait_for_idle(self, timeout: float = 5.0) -> None: + async def idle() -> None: + while True: + if not self._tasks and self.repository.dispatching_count() == 0: + return + await asyncio.sleep(0.01) + + await asyncio.wait_for(idle(), timeout=timeout) + + async def _dispatch_loop(self) -> None: + while not self._stopping: + try: + await asyncio.to_thread(self.repository.expire_interactions) + if self.capabilities is not None: + await asyncio.to_thread(self.capabilities.expire_leases) + while len(self._tasks) < self.global_concurrency: + run = await asyncio.to_thread(self.repository.claim_next) + if run is None: + break + task = asyncio.create_task( + self._run_claimed(run.id), + name=f"ai2apps-agent-{run.id}", + ) + self._tasks[run.id] = task + task.add_done_callback( + lambda _task, run_id=run.id: self._task_done(run_id) + ) + self._wake.clear() + with suppress(TimeoutError): + await asyncio.wait_for(self._wake.wait(), timeout=0.25) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("AI2Apps Agent dispatcher pass failed") + await asyncio.sleep(0.1) + + def _task_done(self, run_id: str) -> None: + task = self._tasks.pop(run_id, None) + if task is not None and not task.cancelled(): + error = task.exception() + if error is not None: + logger.error( + "Agent task %s escaped runtime handling", + run_id, + exc_info=(type(error), error, error.__traceback__), + ) + try: + run = self.repository.get_run(run_id) + if run.status in { + AgentRunStatus.COMPLETED, + AgentRunStatus.FAILED, + AgentRunStatus.CANCELLED, + }: + self._notify_run_terminal(run_id) + except Exception: + logger.exception("Could not inspect Agent Run %s for cleanup", run_id) + self.wake() + + async def _run_claimed(self, run_id: str) -> None: + active_tool_step = None + active_tool_effects: tuple[str, ...] = () + active_model_step = None + try: + run = await asyncio.to_thread( + self.repository.transition, + run_id, + expected={AgentRunStatus.PLANNING}, + status=AgentRunStatus.RUNNING, + ) + definition, run, _, steps, interactions = await asyncio.to_thread( + self.repository.snapshot, run_id + ) + executor = self._executors.get(definition.executor_key) + if executor is None: + await self._fail( + run_id, + "executor_unavailable", + f"Agent executor is not bound: {definition.executor_key}", + ) + return + remaining = (run.deadline_at - datetime.now(UTC)).total_seconds() + if remaining <= 0: + await self._fail( + run_id, "run_deadline_exceeded", "Agent deadline expired" + ) + return + context = AgentExecutionContext(definition, run, steps, interactions) + await asyncio.to_thread( + self.repository.update_status_line, + run_id, + phase="planning", + text="Examining the conversation", + presentation="pulse", + content={ + "tone": "info", + "effect": "shimmer", + "icon": "sparkles", + "detail": f"Choosing the next action · step {run.current_step + 1}", + }, + ) + async with asyncio.timeout(remaining): + action = await _await_action(executor(context)) + if isinstance(action, CompleteAction): + await asyncio.to_thread( + self.repository.transition, + run_id, + expected={AgentRunStatus.RUNNING}, + status=AgentRunStatus.COMPLETED, + output=action.output, + ) + elif isinstance(action, FailAction): + await self._fail( + run_id, + action.code, + action.message, + retryable=action.retryable, + ) + elif isinstance(action, StatusAction): + await asyncio.to_thread( + self.repository.update_status_line, + run_id, + phase=action.phase, + text=action.text, + presentation=action.presentation, + progress=action.progress, + content=action.content, + ) + await self._requeue(run_id, action.text) + elif isinstance(action, ContinueAction): + await self._requeue(run_id, action.status_text) + elif isinstance(action, InteractionAction): + await asyncio.to_thread( + self.repository.create_interaction, + run_id, + request_key=action.request_key, + kind=action.kind, + prompt=action.prompt, + response_schema=action.response_schema, + ui_hints=action.ui_hints, + request=action.request, + timeout_seconds=action.timeout_seconds, + ) + elif isinstance(action, ToolCallAction): + if run.current_step >= self._step_budget(run, definition): + await self._fail( + run_id, + "max_steps_exceeded", + "Agent step budget exhausted", + ) + return + await asyncio.to_thread( + self.repository.update_status_line, + run_id, + phase="tool_preparing", + text=f"Preparing {action.tool_name}", + presentation="pulse", + content={ + "tone": "info", + "effect": "shimmer", + "icon": "activity", + "detail": "Checking permissions and Tool availability", + "tool_name": action.tool_name, + }, + ) + tool = await asyncio.to_thread( + self.tools.repository.get_tool, action.tool_name + ) + # GrantLease/policy is authoritative once M4 is configured. + # The Run JSON field remains a response compatibility cache only. + effective_capabilities = ( + set() if self.capability_policy is not None + else set(run.granted_capabilities) + ) + required_capabilities = self.tools.required_capabilities( + tool, action.arguments + ) + missing = tuple(sorted(required_capabilities - effective_capabilities)) + decision = None + if missing and self.capability_policy is not None: + decision = await self.capability_policy.evaluate( + run_id=run.id, + agent_key=definition.agent_key, + tool_name=action.tool_name, + capabilities=missing, + effects=tool.effects, + arguments=action.arguments, + ) + effective_capabilities.update(decision.allowed_capabilities) + if self.capabilities is not None: + await asyncio.to_thread( + self.capabilities.record_decision, + run_id=run.id, + interaction_id=None, + decision=decision.effect, + source=decision.source, + capabilities=missing, + tool_name=action.tool_name, + effects=tool.effects, + matched_policy_ids=decision.matched_policy_ids, + evidence={ + **(decision.evidence or {}), + "matched_lease_ids": decision.matched_lease_ids, + }, + ) + missing = tuple( + sorted(required_capabilities - effective_capabilities) + ) + if decision is not None and decision.effect is PolicyEffect.DENY: + await self._fail( + run_id, + "capability_policy_denied", + f"Capability policy denied {action.tool_name}", + ) + return + if missing: + preview = action_preview( + action.tool_name, tool.effects, action.arguments + ) + await asyncio.to_thread( + self.repository.create_interaction, + run_id, + request_key=f"capability:{action.call_id}", + kind=InteractionKind.APPROVAL, + prompt=f"Allow {preview['summary']}?", + response_schema={ + "type": "object", + "properties": { + "decision": { + "type": "string", + "enum": ["approve", "deny"], + }, + "scope": { + "type": "string", + "enum": [ + "once", "run", "session", "agent", "app" + ], + }, + }, + "required": ["decision"], + "additionalProperties": False, + }, + ui_hints={ + "control": "approval", + "default_scope": "once", + "scopes": ["once", "run", "session", "agent", "app"], + "operation_class": preview["operation_class"], + "risk_level": preview["risk_level"], + }, + request={ + "tool_name": action.tool_name, + "arguments": preview["arguments"], + "capabilities": missing, + "effects": list(tool.effects), + "action_preview": preview, + "resource_selector": preview["resource_selector"], + }, + timeout_seconds=86_400, + ) + return + if decision is not None and self.capabilities is not None: + # A once grant is consumed before dispatch. A crash can therefore + # never replay authority without asking the user again. + await asyncio.to_thread( + self.capabilities.consume_single_use_leases, + decision.matched_lease_ids, + ) + step, created = await asyncio.to_thread( + self.repository.create_step, + run_id, + action_key=action.call_id, + kind="tool", + input=action.arguments, + tool_name=action.tool_name, + ) + active_tool_step = step + active_tool_effects = tool.effects + if not created: + if step.status is RunStepStatus.COMPLETED: + await self._requeue(run_id, f"Completed {action.tool_name}") + elif step.status is RunStepStatus.UNCERTAIN: + await asyncio.to_thread( + self.repository.transition, + run_id, + expected={AgentRunStatus.RUNNING}, + status=AgentRunStatus.INTERRUPTED, + error={"code": "uncertain_tool_side_effect"}, + ) + else: + await self._fail( + run_id, + "tool_step_not_retriable", + f"Tool step is {step.status.value}", + ) + return + try: + await asyncio.to_thread( + self.repository.update_status_line, + run_id, + phase="tool", + text=f"Running {action.tool_name}", + presentation="indeterminate", + ) + + async def report_tool_progress(update: dict) -> None: + await asyncio.to_thread( + self.repository.update_status_line, + run_id, + phase=str(update.get("phase") or "tool"), + text=str( + update.get("text") + or f"Running {action.tool_name}" + ), + presentation=( + "progress" + if update.get("progress") is not None + else "pulse" + ), + progress=update.get("progress"), + content=update.get("content") or {}, + ) + + result = await self.tools.execute( + action.tool_name, + action.arguments, + context=ToolCallContext( + caller_id=f"agent:{definition.agent_key}", + session_id=run.session_id, + granted_capabilities=frozenset(effective_capabilities), + trace_id=run.id, + progress_reporter=report_tool_progress, + ), + timeout_ms=action.timeout_ms, + ) + except ToolGatewayError as error: + await asyncio.to_thread( + self.repository.settle_step, + step.id, + status=RunStepStatus.FAILED, + error={"code": error.code, "message": str(error)}, + ) + await self._fail( + run_id, error.code, str(error), retryable=error.retryable + ) + return + await asyncio.to_thread( + self.repository.settle_step, + step.id, + status=RunStepStatus.COMPLETED, + output=result.output, + ) + active_tool_step = None + await self._requeue(run_id, f"Completed {action.tool_name}") + elif isinstance(action, ModelCallAction): + if run.current_step >= self._step_budget(run, definition): + await self._fail( + run_id, + "max_steps_exceeded", + "Agent step budget exhausted", + ) + return + step, created = await asyncio.to_thread( + self.repository.create_step, + run_id, + action_key=action.call_id, + kind="model", + input=action.request, + ) + active_model_step = step + if not created: + if step.status is RunStepStatus.COMPLETED: + await self._requeue(run_id, "Model response received") + else: + await self._fail( + run_id, + "model_step_not_retriable", + f"Model step is {step.status.value}", + ) + return + if self._model_provider is None: + await asyncio.to_thread( + self.repository.settle_step, + step.id, + status=RunStepStatus.FAILED, + error={"code": "model_provider_unavailable"}, + ) + await self._fail( + run_id, + "model_provider_unavailable", + "Model Runtime provider is not bound", + ) + return + try: + model_name = str(action.request.get("model") or "the model") + model_output = await _await_action( + self._run_model_call( + run_id, + action.request, + model_name=model_name, + step_sequence=step.sequence, + ) + ) + except Exception as error: + await asyncio.to_thread( + self.repository.settle_step, + step.id, + status=RunStepStatus.FAILED, + error={"code": "model_provider_error", "message": str(error)}, + ) + await self._fail( + run_id, + "model_provider_error", + str(error), + retryable=True, + ) + return + await asyncio.to_thread( + self.repository.update_status_line, + run_id, + phase="model_interpreting", + text="Interpreting the model response", + presentation="pulse", + content={ + "tone": "info", + "effect": "shimmer", + "icon": "sparkles", + "detail": "Selecting a Tool call or final response", + "model": model_name, + "step": step.sequence, + }, + ) + await asyncio.to_thread( + self.repository.settle_step, + step.id, + status=RunStepStatus.COMPLETED, + output=model_output, + ) + active_model_step = None + await self._requeue(run_id, "Model response received") + else: + await self._fail( + run_id, + "invalid_agent_action", + f"Executor returned unsupported action: {type(action).__name__}", + ) + except TimeoutError: + await self._fail(run_id, "run_deadline_exceeded", "Agent deadline expired") + except asyncio.CancelledError: + current = await asyncio.to_thread(self.repository.get_run, run_id) + if active_model_step is not None: + await asyncio.to_thread( + self.repository.abandon_step_for_retry, + active_model_step.id, + error={"code": "runtime_stopped_during_model"}, + ) + if ( + active_tool_step is not None + and current.status is AgentRunStatus.CANCELLED + ): + await asyncio.to_thread( + self.repository.settle_step, + active_tool_step.id, + status=( + RunStepStatus.CANCELLED + if not active_tool_effects + else RunStepStatus.UNCERTAIN + ), + error={"code": "user_cancelled_during_tool"}, + ) + if current.status is AgentRunStatus.CANCELLED: + raise + if ( + current.status is AgentRunStatus.INTERRUPTED + and (current.error or {}).get("code") == "user_paused" + ): + if active_tool_step is not None: + if active_tool_effects: + await asyncio.to_thread( + self.repository.settle_step, + active_tool_step.id, + status=RunStepStatus.UNCERTAIN, + error={"code": "user_paused_during_tool"}, + ) + else: + await asyncio.to_thread( + self.repository.abandon_step_for_retry, + active_tool_step.id, + error={"code": "user_paused_during_tool"}, + ) + raise + if active_tool_step is not None: + if active_tool_effects: + await asyncio.to_thread( + self.repository.settle_step, + active_tool_step.id, + status=RunStepStatus.UNCERTAIN, + error={"code": "runtime_stopped_during_tool"}, + ) + target = AgentRunStatus.INTERRUPTED + else: + # Change the abandoned step's action key so the durable + # executor can safely create a fresh attempt after restart. + await asyncio.to_thread( + self.repository.abandon_step_for_retry, + active_tool_step.id, + error={"code": "runtime_stopped_during_tool"}, + ) + target = AgentRunStatus.QUEUED + else: + target = AgentRunStatus.QUEUED + await asyncio.to_thread( + self.repository.transition, + run_id, + expected={AgentRunStatus.RUNNING, AgentRunStatus.PLANNING}, + status=target, + error=( + {"code": "runtime_stopped"} + if target is AgentRunStatus.QUEUED + else {"code": "uncertain_tool_side_effect"} + ), + ) + raise + except Exception as exc: + logger.exception("AgentRun %s failed", run_id) + try: + await self._fail(run_id, "agent_runtime_error", str(exc)) + except Exception: + logger.exception("Failed to persist AgentRun %s failure", run_id) + + async def _requeue(self, run_id: str, text: str) -> None: + await asyncio.to_thread( + self.repository.transition, + run_id, + expected={AgentRunStatus.RUNNING}, + status=AgentRunStatus.QUEUED, + status_text=text, + ) + self.wake() + + async def _fail( + self, + run_id: str, + code: str, + message: str, + *, + retryable: bool = False, + ) -> None: + current = await asyncio.to_thread(self.repository.get_run, run_id) + if current.status in { + AgentRunStatus.COMPLETED, + AgentRunStatus.FAILED, + AgentRunStatus.CANCELLED, + }: + return + await asyncio.to_thread( + self.repository.transition, + run_id, + expected={current.status}, + status=AgentRunStatus.FAILED, + error={"code": code, "message": message, "retryable": retryable}, + status_text=message or "Failed", + presentation="error", + ) + + +def diagnostic_executor(context: AgentExecutionContext) -> AgentAction: + """Deterministic built-in Agent used to qualify scheduling and UI protocols.""" + + mode = context.run.input.get("mode", "echo") + if mode in {"menu", "text", "file", "approval"}: + key = f"diagnostic:{mode}" + interaction = context.interaction(key) + if interaction is None or interaction.status is InteractionStatus.PENDING: + if mode == "menu": + schema = { + "type": "object", + "properties": { + "choice": {"type": "string", "enum": ["alpha", "beta"]} + }, + "required": ["choice"], + "additionalProperties": False, + } + hints = {"control": "menu", "options": ["alpha", "beta"]} + kind = InteractionKind.MENU + elif mode == "file": + schema = { + "type": "object", + "properties": { + "resource_handle": {"type": "string", "minLength": 1} + }, + "required": ["resource_handle"], + "additionalProperties": False, + } + hints = {"control": "file", "returns": "resource_handle"} + kind = InteractionKind.FILE + elif mode == "approval": + schema = { + "type": "object", + "properties": { + "decision": { + "type": "string", + "enum": ["approve", "deny"], + } + }, + "required": ["decision"], + "additionalProperties": False, + } + hints = {"control": "approval"} + kind = InteractionKind.APPROVAL + else: + schema = { + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + "additionalProperties": False, + } + hints = {"control": "text"} + kind = InteractionKind.TEXT + return InteractionAction( + request_key=key, + kind=kind, + prompt=f"Diagnostic {mode} input", + response_schema=schema, + ui_hints=hints, + ) + return CompleteAction( + { + "mode": mode, + "response": interaction.response, + "status": interaction.status, + } + ) + if mode == "tool": + step = context.step("diagnostic-tool") + if step is None: + return ToolCallAction( + call_id="diagnostic-tool", + tool_name=context.run.input.get("tool_name", "system.echo"), + arguments=context.run.input.get("arguments", {"value": "agent"}), + ) + if step.status is RunStepStatus.COMPLETED: + return CompleteAction({"tool_output": step.output}) + return FailAction("diagnostic_tool_failed", f"Tool step is {step.status.value}") + if mode == "model": + step = context.step("diagnostic-model") + if step is None: + return ModelCallAction( + call_id="diagnostic-model", + request=context.run.input.get( + "request", + { + "model": context.run.input.get("model", ""), + "messages": [{"role": "user", "content": "Say hello"}], + }, + ), + ) + if step.status is RunStepStatus.COMPLETED: + return CompleteAction({"model_output": step.output}) + return FailAction( + "diagnostic_model_failed", f"Model step is {step.status.value}" + ) + return CompleteAction( + { + "echo": { + key: value + for key, value in context.run.input.items() + if key not in {"invocation", "parameters"} + } + } + ) + + +def install_diagnostic_agent( + repository: AgentRepository, runtime: AgentRuntime +) -> None: + repository.ensure_definition( + agent_key="ai2apps.diagnostic-agent", + package_version="1.0.0", + display_name="Diagnostic Agent", + description="Qualifies asynchronous Agent scheduling and interaction contracts.", + executor_key="builtin:diagnostic-agent", + manifest={ + "builtin": True, + "discoverable": False, + "invocation_schema": {"type": "object", "properties": {}}, + }, + ) + runtime.bind_executor("builtin:diagnostic-agent", diagnostic_executor) diff --git a/ai2apps/api/__init__.py b/ai2apps/api/__init__.py new file mode 100644 index 00000000..9bc5ab0d --- /dev/null +++ b/ai2apps/api/__init__.py @@ -0,0 +1,5 @@ +"""AI2Apps platform API surface.""" + +from .router import create_ai2apps_router + +__all__ = ["create_ai2apps_router"] diff --git a/ai2apps/api/agents.py b/ai2apps/api/agents.py new file mode 100644 index 00000000..1bfb8ae9 --- /dev/null +++ b/ai2apps/api/agents.py @@ -0,0 +1,692 @@ +"""Asynchronous AgentRun, status, interaction, and cancellation APIs.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, Header, Query +from fastapi.responses import JSONResponse, StreamingResponse +from pydantic import BaseModel, Field + +from ai2apps.agents import ( + AgentDefinitionRecord, + AgentDefinitionStatus, + AgentRunRecord, + AgentRunStatus, + InteractionRecord, + RunStepRecord, + StatusLineRecord, +) +from ai2apps.api.errors import platform_error_response, repository_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.core import RepositoryError +from ai2apps.events.stream import stream_events +from ai2apps.platform_runtime import PlatformRuntime + + +class AgentDefinitionResponse(BaseModel): + id: str + agent_key: str + package_version: str + display_name: str + description: str + source: str + status: str + executor_key: str + concurrency_group: str | None + concurrency_limit: int | None + max_steps: int + timeout_seconds: int + resume_policy: str + manifest: dict[str, Any] + revision: int + created_at: datetime + updated_at: datetime + discoverable: bool + invocation_schema: dict[str, Any] + ui_hints: dict[str, Any] + aliases: list[str] + + @classmethod + def from_record(cls, record: AgentDefinitionRecord): + manifest = record.manifest or {} + aliases = [record.agent_key] + manifest_aliases = manifest.get("aliases", []) + if not isinstance(manifest_aliases, list): + manifest_aliases = [] + aliases.extend( + str(item).strip() + for item in manifest_aliases + if str(item).strip() + ) + invocation_schema = manifest.get("invocation_schema") + if not isinstance(invocation_schema, dict): + invocation_schema = {"type": "object", "properties": {}} + ui_hints = manifest.get("invocation_ui") + if not isinstance(ui_hints, dict): + ui_hints = {} + return cls( + **{ + field: getattr(record, field) + for field in cls.model_fields + if hasattr(record, field) + }, + discoverable=bool( + manifest.get( + "discoverable", + record.agent_key != "ai2apps.diagnostic-agent", + ) + ), + invocation_schema=invocation_schema, + ui_hints=ui_hints, + aliases=list(dict.fromkeys(aliases)), + ) + + +class AgentDefinitionListResponse(BaseModel): + items: list[AgentDefinitionResponse] + + +class StatusLineResponse(BaseModel): + id: str + phase: str + text: str + presentation: str + progress: float | None + content: dict[str, Any] + revision: int + + @classmethod + def from_record(cls, record: StatusLineRecord): + return cls(**{field: getattr(record, field) for field in cls.model_fields}) + + +class RunStepResponse(BaseModel): + id: str + sequence: int + action_key: str + kind: str + status: str + tool_name: str | None + input: dict[str, Any] + output: dict[str, Any] | None + error: dict[str, Any] | None + + @classmethod + def from_record(cls, record: RunStepRecord): + return cls(**{field: getattr(record, field) for field in cls.model_fields}) + + +class InteractionResponse(BaseModel): + id: str + request_key: str + kind: str + status: str + prompt: str + response_schema: dict[str, Any] + ui_hints: dict[str, Any] + request: dict[str, Any] + response: dict[str, Any] | None + deadline_at: datetime + revision: int + + @classmethod + def from_record(cls, record: InteractionRecord): + return cls(**{field: getattr(record, field) for field in cls.model_fields}) + + +class AgentRunResponse(BaseModel): + id: str + agent_definition_id: str + agent_key: str + agent_display_name: str + agent_package_version: str + session_id: str + parent_run_id: str | None + root_run_id: str + depth: int + delegation: dict[str, Any] + child_run_ids: list[str] + status: str + priority: int + input: dict[str, Any] + output: dict[str, Any] | None + error: dict[str, Any] | None + current_step: int + budget: dict[str, int] + usage: dict[str, int] + revision: int + deadline_at: datetime + created_at: datetime + updated_at: datetime + started_at: datetime | None + finished_at: datetime | None + status_line: StatusLineResponse + steps: list[RunStepResponse] + interactions: list[InteractionResponse] + event_stream_url: str + + +class AgentRunBudgetRequest(BaseModel): + max_steps: int | None = Field(default=None, ge=1, le=10_000) + timeout_seconds: int | None = Field(default=None, ge=1, le=604_800) + max_model_tokens: int | None = Field(default=None, ge=1) + + +class AgentRunCreateRequest(BaseModel): + agent: str = "ai2apps.general-agent" + input: dict[str, Any] = Field(default_factory=dict) + idempotency_key: str | None = None + priority: int = Field(default=0, ge=-100, le=100) + budget: AgentRunBudgetRequest | None = None + + +class AgentRunListResponse(BaseModel): + items: list[AgentRunResponse] + + +class InteractionSubmitRequest(BaseModel): + response: dict[str, Any] + response_id: str = Field(min_length=1) + + +class InteractionDecisionRequest(BaseModel): + response_id: str = Field(min_length=1) + scope: str = Field(default="run", pattern="^(run|session|agent|app)$") + + +class ResumeRunRequest(BaseModel): + uncertain_resolution: str | None = None + + +class RetryRunRequest(BaseModel): + idempotency_key: str | None = None + + +def _runtime_or_error(runtime_provider: PlatformRuntimeProvider): + runtime = runtime_provider() + if ( + runtime is None + or runtime.agents is None + or runtime.agent_runtime is None + or runtime.events is None + or runtime.notifications is None + ): + return platform_error_response( + status_code=503, + code="agent_runtime_not_ready", + message="AI2Apps Agent Runtime is not ready.", + retryable=True, + ) + return runtime + + +def _run_response(runtime: PlatformRuntime, run: AgentRunRecord) -> AgentRunResponse: + definition = runtime.agents.get_definition(run.agent_definition_id) + status = runtime.agents.get_status_line(run.id) + steps = runtime.agents.list_steps(run.id) + interactions = runtime.agents.list_interactions(run.id) + requested_budget = run.input.get("run_budget") + requested_budget = requested_budget if isinstance(requested_budget, dict) else {} + max_steps = requested_budget.get("max_steps", definition.max_steps) + if not isinstance(max_steps, int) or max_steps <= 0: + max_steps = definition.max_steps + max_steps = min(max_steps, definition.max_steps) + timeout_seconds = requested_budget.get( + "timeout_seconds", definition.timeout_seconds + ) + if not isinstance(timeout_seconds, int) or timeout_seconds <= 0: + timeout_seconds = definition.timeout_seconds + timeout_seconds = min(timeout_seconds, definition.timeout_seconds) + configured_tokens = definition.manifest.get("max_total_model_tokens", 100_000) + max_model_tokens = requested_budget.get("max_model_tokens", configured_tokens) + if not isinstance(max_model_tokens, int) or max_model_tokens <= 0: + max_model_tokens = 100_000 + if isinstance(configured_tokens, int) and configured_tokens > 0: + max_model_tokens = min(max_model_tokens, configured_tokens) + model_tokens = 0 + for step in steps: + if step.kind != "model" or not isinstance(step.output, dict): + continue + usage = step.output.get("usage") + total = usage.get("total_tokens") if isinstance(usage, dict) else None + if isinstance(total, int) and total > 0: + model_tokens += total + return AgentRunResponse( + id=run.id, + agent_definition_id=run.agent_definition_id, + agent_key=definition.agent_key, + agent_display_name=definition.display_name, + agent_package_version=(run.input.get("invocation") or {}).get( + "package_version", definition.package_version + ), + session_id=run.session_id, + parent_run_id=run.parent_run_id, + root_run_id=run.root_run_id, + depth=run.depth, + delegation=run.delegation, + child_run_ids=[child.id for child in runtime.agents.list_children(run.id)], + status=run.status.value, + priority=run.priority, + input=run.input, + output=run.output, + error=run.error, + current_step=run.current_step, + budget={ + "max_steps": max_steps, + "timeout_seconds": timeout_seconds, + "max_model_tokens": max_model_tokens, + }, + usage={"steps": run.current_step, "model_tokens": model_tokens}, + revision=run.revision, + deadline_at=run.deadline_at, + created_at=run.created_at, + updated_at=run.updated_at, + started_at=run.started_at, + finished_at=run.finished_at, + status_line=StatusLineResponse.from_record(status), + steps=[RunStepResponse.from_record(step) for step in steps], + interactions=[ + InteractionResponse.from_record(interaction) for interaction in interactions + ], + event_stream_url=f"/v1/platform/agent-runs/{run.id}/events", + ) + + +def create_agent_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter() + + @router.get("/agents", response_model=AgentDefinitionListResponse) + def list_agents(): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + return AgentDefinitionListResponse( + items=[ + AgentDefinitionResponse.from_record(item) + for item in runtime.agents.list_definitions() + ] + ) + + @router.get("/agents/{agent_key}/management") + def agent_management(agent_key: str): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + definition = runtime.agents.get_definition(agent_key) + packages = [] + patches = [] + effective = None + if runtime.extension_repository is not None: + from ai2apps.extensions import UnitKind + + packages = [ + { + "id": item.id, + "version": item.version, + "digest": item.digest, + "publisher": item.publisher_key, + "status": item.status.value, + "verification": item.verification, + "installed_at": item.installed_at, + "activated_at": item.activated_at, + } + for item in runtime.extension_repository.installed( + UnitKind.AGENT, definition.agent_key + ) + ] + patches = [ + { + "id": item.id, + "version": item.version, + "digest": item.digest, + "base_digest": item.base_digest, + "intent": item.intent, + "rebase_policy": item.rebase_policy.value, + "status": item.status.value, + "conflict": item.conflict, + "stack_order": item.stack_order, + "audit": item.audit, + } + for item in runtime.extension_repository.patches( + UnitKind.AGENT, definition.agent_key + ) + ] + effective_record = runtime.extension_repository.effective( + UnitKind.AGENT, definition.agent_key + ) + if effective_record is not None: + effective = { + "id": effective_record.id, + "upstream_digest": effective_record.upstream_digest, + "patch_set_digest": effective_record.patch_set_digest, + "effective_digest": effective_record.effective_digest, + "effective_version": effective_record.effective_version, + "audit": effective_record.audit, + "status": effective_record.status, + "revision": effective_record.revision, + } + recent = runtime.agents.list_runs( + agent_definition_id=definition.id, limit=20 + ) + return { + "definition": AgentDefinitionResponse.from_record(definition), + "run_counts": runtime.agents.run_counts(definition.id), + "recent_runs": [_run_response(runtime, run) for run in recent], + "packages": packages, + "patches": patches, + "effective_definition": effective, + } + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/agents/{agent_key}/enable", response_model=AgentDefinitionResponse) + def enable_agent(agent_key: str): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + return AgentDefinitionResponse.from_record( + runtime.agents.set_definition_status( + agent_key, AgentDefinitionStatus.ENABLED + ) + ) + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/agents/{agent_key}/disable", response_model=AgentDefinitionResponse) + def disable_agent(agent_key: str): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + return AgentDefinitionResponse.from_record( + runtime.agents.set_definition_status( + agent_key, AgentDefinitionStatus.DISABLED + ) + ) + except RepositoryError as error: + return repository_error_response(error) + + @router.get("/agent-runs", response_model=AgentRunListResponse) + def list_runs( + agent: str | None = None, + status: str | None = None, + root_only: bool = False, + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + definition_id = ( + None if agent is None else runtime.agents.get_definition(agent).id + ) + parsed_status = None if status is None else AgentRunStatus(status) + runs = runtime.agents.list_runs( + agent_definition_id=definition_id, + status=parsed_status, + root_only=root_only, + limit=limit, + offset=offset, + ) + return AgentRunListResponse( + items=[_run_response(runtime, run) for run in runs] + ) + except RepositoryError as error: + return repository_error_response(error) + except ValueError as error: + return platform_error_response( + status_code=422, + code="invalid_agent_run_filter", + message=str(error), + ) + + @router.post( + "/sessions/{session_id}/agent-runs", + response_model=AgentRunResponse, + status_code=202, + ) + def create_run( + session_id: str, + request: AgentRunCreateRequest, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + x_trace_id: str | None = Header(default=None), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + if ( + idempotency_key + and request.idempotency_key + and idempotency_key != request.idempotency_key + ): + return platform_error_response( + status_code=400, + code="idempotency_key_mismatch", + message="Header and body idempotency keys must match.", + ) + try: + run, _ = runtime.agents.create_run( + session_id=session_id, + agent_key=request.agent, + input=request.input, + idempotency_key=idempotency_key or request.idempotency_key, + priority=request.priority, + trace_id=x_trace_id, + budget=( + None + if request.budget is None + else request.budget.model_dump(exclude_none=True) + ), + ) + runtime.agent_runtime.wake() + return _run_response(runtime, run) + except RepositoryError as error: + return repository_error_response(error) + except ValueError as error: + return platform_error_response( + status_code=422, + code="invalid_agent_parameters", + message=str(error), + ) + + @router.get("/agent-runs/{run_id}", response_model=AgentRunResponse) + def get_run(run_id: str): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + return _run_response(runtime, runtime.agents.get_run(run_id)) + except RepositoryError as error: + return repository_error_response(error) + + @router.get( + "/agent-runs/{run_id}/children", + response_model=list[AgentRunResponse], + ) + def list_child_runs(run_id: str): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + runtime.agents.get_run(run_id) + return [ + _run_response(runtime, child) + for child in runtime.agents.list_children(run_id) + ] + except RepositoryError as error: + return repository_error_response(error) + + @router.get("/agent-runs/{run_id}/events", response_model=None) + async def run_events( + run_id: str, + after: int | None = Query(default=None, ge=0), + last_event_id: str | None = Header(default=None, alias="Last-Event-ID"), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + runtime.agents.get_run(run_id) + except RepositoryError as error: + return repository_error_response(error) + cursor = after + if cursor is None and last_event_id is not None: + try: + cursor = int(last_event_id) + except ValueError: + return platform_error_response( + status_code=400, + code="invalid_event_cursor", + message="Last-Event-ID must be a non-negative integer.", + ) + if cursor < 0: + return platform_error_response( + status_code=400, + code="invalid_event_cursor", + message="Last-Event-ID must be a non-negative integer.", + ) + return StreamingResponse( + stream_events( + runtime.events, + runtime.notifications, + after_sequence=cursor or 0, + subject_id=run_id, + ), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + @router.post( + "/agent-runs/{run_id}/interactions/{interaction_id}/respond", + response_model=AgentRunResponse, + ) + def respond_interaction( + run_id: str, + interaction_id: str, + request: InteractionSubmitRequest, + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + runtime.agents.respond_interaction( + run_id, + interaction_id, + response=request.response, + response_id=request.response_id, + ) + runtime.agent_runtime.wake() + return _run_response(runtime, runtime.agents.get_run(run_id)) + except RepositoryError as error: + return repository_error_response(error) + + async def decide( + run_id: str, + interaction_id: str, + request: InteractionDecisionRequest, + decision: str, + ): + return respond_interaction( + run_id, + interaction_id, + InteractionSubmitRequest( + response={ + "decision": decision, + **({"scope": request.scope} if decision == "approve" else {}), + }, + response_id=request.response_id, + ), + ) + + @router.post( + "/agent-runs/{run_id}/approve/{interaction_id}", + response_model=AgentRunResponse, + ) + async def approve( + run_id: str, + interaction_id: str, + request: InteractionDecisionRequest, + ): + return await decide(run_id, interaction_id, request, "approve") + + @router.post( + "/agent-runs/{run_id}/deny/{interaction_id}", + response_model=AgentRunResponse, + ) + async def deny( + run_id: str, + interaction_id: str, + request: InteractionDecisionRequest, + ): + return await decide(run_id, interaction_id, request, "deny") + + @router.post("/agent-runs/{run_id}/cancel", response_model=AgentRunResponse) + def cancel_run(run_id: str): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + return _run_response(runtime, runtime.agent_runtime.cancel(run_id)) + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/agent-runs/{run_id}/pause", response_model=AgentRunResponse) + async def pause_run(run_id: str): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + return _run_response(runtime, runtime.agent_runtime.pause(run_id)) + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/agent-runs/{run_id}/resume", response_model=AgentRunResponse) + def resume_run(run_id: str, request: ResumeRunRequest): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + run = runtime.agent_runtime.resume( + run_id, + uncertain_resolution=request.uncertain_resolution, + ) + return _run_response(runtime, run) + except (RepositoryError, ValueError) as error: + if isinstance(error, RepositoryError): + return repository_error_response(error) + return platform_error_response( + status_code=422, + code="invalid_resume_resolution", + message=str(error), + ) + + @router.post( + "/agent-runs/{run_id}/retry", + response_model=AgentRunResponse, + status_code=202, + ) + def retry_run( + run_id: str, + request: RetryRunRequest, + x_trace_id: str | None = Header(default=None), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + run, _ = runtime.agents.retry_run( + run_id, + idempotency_key=request.idempotency_key, + trace_id=x_trace_id, + ) + runtime.agent_runtime.wake() + return _run_response(runtime, run) + except RepositoryError as error: + return repository_error_response(error) + + return router diff --git a/ai2apps/api/browser.py b/ai2apps/api/browser.py new file mode 100644 index 00000000..7b6fc549 --- /dev/null +++ b/ai2apps/api/browser.py @@ -0,0 +1,69 @@ +"""User-only control handoff endpoints for the managed browser.""" + +from __future__ import annotations + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from ai2apps.api.errors import platform_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.browser import BrowserError + + +def create_browser_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter() + + def manager_or_error(): + runtime = runtime_provider() + if runtime is None or runtime.browser is None: + return platform_error_response( + status_code=503, + code="browser_unavailable", + message="The managed browser runtime is unavailable.", + retryable=True, + ) + return runtime.browser + + def error_response(exc: BrowserError): + return platform_error_response( + status_code=409, + code=exc.code, + message=str(exc), + retryable=False, + ) + + @router.get("/browser/status") + async def browser_status(): + manager = manager_or_error() + if isinstance(manager, JSONResponse): + return manager + return await manager.get_status() + + @router.post("/browser/user-control/begin") + async def begin_user_control(): + manager = manager_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + return await manager.begin_user_control() + except BrowserError as exc: + return error_response(exc) + + @router.post("/browser/user-control/complete") + async def complete_user_control(): + manager = manager_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + return await manager.complete_user_control() + except BrowserError as exc: + return error_response(exc) + + @router.post("/browser/close") + async def close_browser(): + manager = manager_or_error() + if isinstance(manager, JSONResponse): + return manager + return await manager.close() + + return router diff --git a/ai2apps/api/capabilities.py b/ai2apps/api/capabilities.py new file mode 100644 index 00000000..ac770adc --- /dev/null +++ b/ai2apps/api/capabilities.py @@ -0,0 +1,155 @@ +"""Capability policy and revocable GrantLease management APIs.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, Query +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from ai2apps.api.errors import platform_error_response, repository_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.capabilities import ( + CapabilityPolicyRecord, + GrantLeaseRecord, + PolicyEffect, +) +from ai2apps.core import RepositoryError + + +class PolicyResponse(BaseModel): + id: str + policy_key: str + effect: str + capability_pattern: str + agent_pattern: str + tool_pattern: str + priority: int + enabled: bool + source: str + conditions: dict[str, Any] + revision: int + + @classmethod + def from_record(cls, value: CapabilityPolicyRecord): + return cls(**{key: getattr(value, key) for key in cls.model_fields}) + + +class PolicyListResponse(BaseModel): + items: list[PolicyResponse] + + +class PolicyPutRequest(BaseModel): + effect: PolicyEffect + capability_pattern: str = Field(min_length=1) + agent_pattern: str = Field(default="*", min_length=1) + tool_pattern: str = Field(default="*", min_length=1) + priority: int = Field(default=0, ge=-10_000, le=10_000) + conditions: dict[str, Any] = Field(default_factory=dict) + + +class GrantLeaseResponse(BaseModel): + id: str + scope: str + scope_id: str + agent_definition_id: str | None + session_id: str + app_instance_id: str + capabilities: list[str] + tool_pattern: str + tool_service_digest: str | None + resource_selector: dict[str, Any] + issued_by: str + evidence: dict[str, Any] + expires_at: datetime | None + revoked_at: datetime | None + revoke_reason: str | None + created_at: datetime + + @classmethod + def from_record(cls, value: GrantLeaseRecord): + data = {key: getattr(value, key) for key in cls.model_fields} + data["capabilities"] = list(value.capabilities) + return cls(**data) + + +class GrantLeaseListResponse(BaseModel): + items: list[GrantLeaseResponse] + + +class RevokeRequest(BaseModel): + reason: str = Field(min_length=1, max_length=500) + + +def create_capability_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter() + + def repository_or_error(): + runtime = runtime_provider() + if runtime is None or runtime.capabilities is None: + return platform_error_response( + status_code=503, + code="capability_runtime_not_ready", + message="AI2Apps Capability Runtime is not ready.", + retryable=True, + ) + return runtime.capabilities + + @router.get("/capability-policies", response_model=PolicyListResponse) + def list_policies(): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + return PolicyListResponse( + items=[PolicyResponse.from_record(x) for x in repository.list_policies()] + ) + + @router.put("/capability-policies/{policy_key}", response_model=PolicyResponse) + def put_policy(policy_key: str, request: PolicyPutRequest): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + record = repository.upsert_policy( + policy_key=policy_key, + effect=request.effect, + capability_pattern=request.capability_pattern, + agent_pattern=request.agent_pattern, + tool_pattern=request.tool_pattern, + priority=request.priority, + source="local", + conditions=request.conditions, + ) + return PolicyResponse.from_record(record) + except ValueError as error: + return platform_error_response( + status_code=422, code="invalid_capability_policy", message=str(error) + ) + + @router.get("/grant-leases", response_model=GrantLeaseListResponse) + def list_grants(include_inactive: bool = Query(default=False)): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + return GrantLeaseListResponse( + items=[ + GrantLeaseResponse.from_record(x) + for x in repository.list_leases(include_inactive=include_inactive) + ] + ) + + @router.post("/grant-leases/{lease_id}/revoke", response_model=GrantLeaseResponse) + def revoke_grant(lease_id: str, request: RevokeRequest): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + return GrantLeaseResponse.from_record( + repository.revoke_lease(lease_id, reason=request.reason) + ) + except RepositoryError as error: + return repository_error_response(error) + + return router diff --git a/ai2apps/api/chat.py b/ai2apps/api/chat.py new file mode 100644 index 00000000..d1453af8 --- /dev/null +++ b/ai2apps/api/chat.py @@ -0,0 +1,379 @@ +"""Chat-friendly aliases over the singleton Chat App's generic resources.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, Header, Query +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field, model_validator + +from ai2apps.api.errors import repository_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.api.resources import _runtime_or_error +from ai2apps.chat import ChatContentRecord, ChatRepository, LegacyChatMessageInput +from ai2apps.core import MessageRole, RepositoryError, SessionStatus +from ai2apps.storage import BuiltinChatRecord, ChatCollectionRecord, ChatThreadRecord + + +class ChatAppResponse(BaseModel): + package_id: str + app_instance_id: str + status: str + selected_thread_id: str | None + collection_revision: int + + @classmethod + def from_record(cls, record: BuiltinChatRecord) -> ChatAppResponse: + return cls( + package_id=record.definition.package_id, + app_instance_id=record.instance.id, + status=record.instance.status.value, + selected_thread_id=record.collection.selected_session_id, + collection_revision=record.collection.revision, + ) + + +class ChatCollectionResponse(BaseModel): + app_instance_id: str + selected_thread_id: str | None + revision: int + + @classmethod + def from_record(cls, record: ChatCollectionRecord) -> ChatCollectionResponse: + return cls( + app_instance_id=record.app_instance_id, + selected_thread_id=record.selected_session_id, + revision=record.revision, + ) + + +class ChatThreadResponse(BaseModel): + id: str + app_instance_id: str + title: str + status: SessionStatus + is_home: bool + pinned: bool + sort_order: int + legacy_thread_id: str | None + revision: int + created_at: datetime + updated_at: datetime + + @classmethod + def from_record(cls, record: ChatThreadRecord) -> ChatThreadResponse: + session = record.session + return cls( + id=session.id, + app_instance_id=session.app_instance_id, + title=session.title, + status=session.status, + is_home=session.is_home, + pinned=record.pinned, + sort_order=record.sort_order, + legacy_thread_id=record.legacy_thread_id, + revision=session.revision, + created_at=session.created_at, + updated_at=session.updated_at, + ) + + +class ChatThreadListResponse(BaseModel): + items: list[ChatThreadResponse] + + +class LegacyChatMessageRequest(BaseModel): + role: MessageRole + content: Any + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ChatThreadCreateRequest(BaseModel): + title: str = "" + pinned: bool = False + legacy_thread_id: str | None = Field(default=None, min_length=1, max_length=512) + session_metadata: dict[str, Any] = Field(default_factory=dict) + legacy_messages: list[LegacyChatMessageRequest] = Field( + default_factory=list, + max_length=10_000, + ) + + @model_validator(mode="after") + def legacy_messages_require_identity(self) -> ChatThreadCreateRequest: + if self.legacy_messages and self.legacy_thread_id is None: + raise ValueError("legacy_messages require legacy_thread_id") + return self + + +class ChatThreadPatchRequest(BaseModel): + expected_revision: int = Field(ge=1) + title: str | None = None + pinned: bool | None = None + + @model_validator(mode="after") + def require_change(self) -> ChatThreadPatchRequest: + if self.title is None and self.pinned is None: + raise ValueError("At least one Chat thread field must change") + return self + + +class ExpectedRevisionRequest(BaseModel): + expected_revision: int = Field(ge=1) + + +class ChatContentRequest(BaseModel): + expected_revision: int = Field(ge=1) + title: str | None = None + session_metadata: dict[str, Any] = Field(default_factory=dict) + messages: list[LegacyChatMessageRequest] = Field(max_length=10_000) + + +class ChatContentResponse(BaseModel): + thread: ChatThreadResponse + session_metadata: dict[str, Any] + messages: list[LegacyChatMessageRequest] + + @classmethod + def from_record(cls, record: ChatContentRecord) -> ChatContentResponse: + return cls( + thread=ChatThreadResponse.from_record(record.thread), + session_metadata=record.metadata, + messages=[ + LegacyChatMessageRequest( + role=message.role, + content=message.content, + metadata=message.metadata, + ) + for message in record.messages + ], + ) + + +def create_chat_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter(prefix="/chat") + + def repository_or_error(): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + return ChatRepository(runtime.database, runtime.events) + + @router.get("", response_model=ChatAppResponse) + def get_chat_app(): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + return ChatAppResponse.from_record(repository.ensure_builtin()) + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/threads", response_model=ChatThreadResponse, status_code=201) + def create_thread( + request: ChatThreadCreateRequest, + x_trace_id: str | None = Header(default=None), + ): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + record, created = repository.create_thread( + title=request.title, + pinned=request.pinned, + legacy_thread_id=request.legacy_thread_id, + metadata=request.session_metadata, + legacy_messages=tuple( + LegacyChatMessageInput( + role=message.role, + content=message.content, + metadata=message.metadata, + ) + for message in request.legacy_messages + ), + trace_id=x_trace_id, + ) + response = ChatThreadResponse.from_record(record) + if not created: + return JSONResponse( + status_code=200, + content=response.model_dump(mode="json"), + ) + return response + except RepositoryError as error: + return repository_error_response(error) + + @router.get("/threads", response_model=ChatThreadListResponse) + def list_threads( + include_archived: bool = False, + include_deleted: bool = False, + limit: int = Query(default=100, ge=1, le=1_000), + ): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + records = repository.list_threads( + include_archived=include_archived, + include_deleted=include_deleted, + limit=limit, + ) + return ChatThreadListResponse( + items=[ChatThreadResponse.from_record(record) for record in records] + ) + + @router.get("/threads/{thread_id}", response_model=ChatThreadResponse) + def get_thread(thread_id: str): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + return ChatThreadResponse.from_record(repository.get_thread(thread_id)) + except RepositoryError as error: + return repository_error_response(error) + + @router.get( + "/threads/{thread_id}/content", + response_model=ChatContentResponse, + ) + def get_thread_content(thread_id: str): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + return ChatContentResponse.from_record(repository.get_content(thread_id)) + except RepositoryError as error: + return repository_error_response(error) + + @router.put( + "/threads/{thread_id}/content", + response_model=ChatContentResponse, + ) + def replace_thread_content( + thread_id: str, + request: ChatContentRequest, + x_trace_id: str | None = Header(default=None), + ): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + record = repository.replace_content( + thread_id, + expected_revision=request.expected_revision, + title=request.title, + metadata=request.session_metadata, + messages=tuple( + LegacyChatMessageInput( + role=message.role, + content=message.content, + metadata=message.metadata, + ) + for message in request.messages + ), + trace_id=x_trace_id, + ) + return ChatContentResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + @router.patch("/threads/{thread_id}", response_model=ChatThreadResponse) + def patch_thread( + thread_id: str, + request: ChatThreadPatchRequest, + x_trace_id: str | None = Header(default=None), + ): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + record = repository.update_thread( + thread_id, + expected_revision=request.expected_revision, + title=request.title, + pinned=request.pinned, + trace_id=x_trace_id, + ) + return ChatThreadResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/threads/{thread_id}/select", response_model=ChatCollectionResponse) + def select_thread( + thread_id: str, + request: ExpectedRevisionRequest, + x_trace_id: str | None = Header(default=None), + ): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + record = repository.select_thread( + thread_id, + expected_revision=request.expected_revision, + trace_id=x_trace_id, + ) + return ChatCollectionResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/threads/{thread_id}/home", response_model=ChatThreadResponse) + def set_home_thread( + thread_id: str, + request: ExpectedRevisionRequest, + x_trace_id: str | None = Header(default=None), + ): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + record = repository.set_home_thread( + thread_id, + expected_revision=request.expected_revision, + trace_id=x_trace_id, + ) + return ChatThreadResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/threads/{thread_id}/archive", response_model=ChatThreadResponse) + def archive_thread( + thread_id: str, + request: ExpectedRevisionRequest, + x_trace_id: str | None = Header(default=None), + ): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + record = repository.update_thread( + thread_id, + expected_revision=request.expected_revision, + status=SessionStatus.ARCHIVED, + trace_id=x_trace_id, + ) + return ChatThreadResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + @router.delete("/threads/{thread_id}", response_model=ChatThreadResponse) + def delete_thread( + thread_id: str, + expected_revision: int = Query(ge=1), + x_trace_id: str | None = Header(default=None), + ): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + record = repository.update_thread( + thread_id, + expected_revision=expected_revision, + status=SessionStatus.DELETED, + trace_id=x_trace_id, + ) + return ChatThreadResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + return router diff --git a/ai2apps/api/cloud.py b/ai2apps/api/cloud.py new file mode 100644 index 00000000..0c977f56 --- /dev/null +++ b/ai2apps/api/cloud.py @@ -0,0 +1,276 @@ +"""Local native-network facade for the AI2Apps Cloud v1 API.""" + +from __future__ import annotations + +from typing import Any + +import httpx +from fastapi import APIRouter, Header, Query +from fastapi.responses import JSONResponse, Response, StreamingResponse +from pydantic import BaseModel, ConfigDict, Field + +from ai2apps.api.errors import platform_error_response +from ai2apps.api.health import PlatformRuntimeProvider + + +class RegisterRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + display_name: str = Field(alias="displayName", min_length=1, max_length=120) + email: str = Field(min_length=3, max_length=320) + password: str = Field(min_length=12, max_length=128) + + +class LoginRequest(BaseModel): + email: str = Field(min_length=3, max_length=320) + password: str = Field(min_length=12, max_length=128) + + +class AdminReauthRequest(BaseModel): + password: str = Field(min_length=12, max_length=128) + + +class EmailRequest(BaseModel): + email: str = Field(min_length=3, max_length=320) + + +class EmailCodeRequest(EmailRequest): + code: str = Field(pattern=r"^[0-9]{8}$") + + +class PasswordResetRequest(EmailCodeRequest): + model_config = ConfigDict(populate_by_name=True) + + new_password: str = Field(alias="newPassword", min_length=12, max_length=128) + + +def _cloud_or_error(runtime_provider: PlatformRuntimeProvider): + runtime = runtime_provider() + cloud = None if runtime is None else getattr(runtime, "cloud", None) + if cloud is None: + return platform_error_response( + status_code=503, + code="cloud_client_not_ready", + message="AI2Apps Cloud client is not ready.", + retryable=True, + ) + return cloud + + +def _forward_response(response: httpx.Response) -> Response: + headers = {} + content_type = response.headers.get("content-type") + retry_after = response.headers.get("retry-after") + if content_type: + headers["content-type"] = content_type + if retry_after: + headers["retry-after"] = retry_after + return Response(content=response.content, status_code=response.status_code, headers=headers) + + +def _transport_error(error: httpx.HTTPError) -> JSONResponse: + if isinstance(error, httpx.TimeoutException): + return platform_error_response( + status_code=504, + code="cloud_timeout", + message="AI2Apps Cloud did not respond in time.", + retryable=True, + ) + return platform_error_response( + status_code=502, + code="cloud_unavailable", + message="AI2Apps Cloud is unavailable.", + retryable=True, + ) + + +def create_cloud_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter(prefix="/cloud", tags=["platform-cloud"]) + + async def call( + method: str, + path: str, + *, + payload: Any | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> Response: + cloud = _cloud_or_error(runtime_provider) + if isinstance(cloud, JSONResponse): + return cloud + try: + response = await cloud.request( + method, path, json=payload, params=params, headers=headers + ) + except httpx.HTTPError as error: + return _transport_error(error) + try: + return _forward_response(response) + finally: + await response.aclose() + + @router.post("/auth/register") + async def register(request: RegisterRequest): + return await call( + "POST", "/v1/auth/register", payload=request.model_dump(by_alias=True) + ) + + @router.post("/auth/email/verify") + async def verify_email(request: EmailCodeRequest): + return await call("POST", "/v1/auth/email/verify", payload=request.model_dump()) + + @router.post("/auth/email/resend") + async def resend_email(request: EmailRequest): + return await call("POST", "/v1/auth/email/resend", payload=request.model_dump()) + + @router.post("/auth/login") + async def login(request: LoginRequest): + return await call("POST", "/v1/auth/login", payload=request.model_dump()) + + @router.post("/auth/logout") + async def logout(): + cloud = _cloud_or_error(runtime_provider) + if isinstance(cloud, JSONResponse): + return cloud + response = await call("POST", "/v1/auth/logout") + if response.status_code < 400: + await cloud.clear_session() + return response + + @router.get("/auth/me") + async def auth_me(): + return await call("GET", "/v1/auth/me") + + @router.post("/admin/reauth") + async def admin_reauth(request: AdminReauthRequest): + return await call( + "POST", "/v1/admin/reauth", payload=request.model_dump() + ) + + @router.post("/auth/password/reset-request") + async def request_password_reset(request: EmailRequest): + return await call( + "POST", "/v1/auth/password/reset-request", payload=request.model_dump() + ) + + @router.post("/auth/password/reset") + async def reset_password(request: PasswordResetRequest): + cloud = _cloud_or_error(runtime_provider) + if isinstance(cloud, JSONResponse): + return cloud + response = await call( + "POST", + "/v1/auth/password/reset", + payload=request.model_dump(by_alias=True), + ) + if response.status_code < 400: + await cloud.clear_session() + return response + + @router.get("/levels") + async def levels(): + return await call("GET", "/v1/levels") + + @router.get("/points") + async def points(): + return await call("GET", "/v1/points") + + @router.get("/points/ledger") + async def point_ledger(limit: int = Query(default=50, ge=1, le=100)): + return await call("GET", "/v1/points/ledger", params={"limit": limit}) + + @router.post("/points/daily-claim") + async def daily_claim(): + return await call("POST", "/v1/points/daily-claim") + + @router.get("/account/entitlements") + async def entitlements(): + return await call("GET", "/v1/account/entitlements") + + @router.get("/ai/models") + async def ai_models(): + return await call("GET", "/v1/ai/models") + + @router.post("/ai/responses") + async def ai_response( + payload: dict[str, Any], + idempotency_key: str = Header( + alias="Idempotency-Key", min_length=8, max_length=160 + ), + ): + cloud = _cloud_or_error(runtime_provider) + if isinstance(cloud, JSONResponse): + return cloud + wants_stream = payload.get("stream", True) is not False + try: + response = await cloud.request( + "POST", + "/v1/ai/responses", + json=payload, + headers={"Idempotency-Key": idempotency_key}, + stream=wants_stream, + ) + except httpx.HTTPError as error: + return _transport_error(error) + if not wants_stream or response.status_code >= 400: + try: + await response.aread() + return _forward_response(response) + finally: + await response.aclose() + + async def body(): + try: + async for chunk in response.aiter_bytes(): + yield chunk + finally: + await response.aclose() + + return StreamingResponse( + body(), + status_code=response.status_code, + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + async def ai_image( + endpoint: str, + payload: dict[str, Any], + idempotency_key: str, + ) -> Response: + """Forward synchronous image calls without retaining image Data URLs.""" + + return await call( + "POST", + f"/v1/ai/images/{endpoint}", + payload=payload, + headers={"Idempotency-Key": idempotency_key}, + ) + + @router.post("/ai/images/generations") + async def ai_image_generation( + payload: dict[str, Any], + idempotency_key: str = Header( + alias="Idempotency-Key", min_length=8, max_length=160 + ), + ): + return await ai_image("generations", payload, idempotency_key) + + @router.post("/ai/images/edits") + async def ai_image_edit( + payload: dict[str, Any], + idempotency_key: str = Header( + alias="Idempotency-Key", min_length=8, max_length=160 + ), + ): + return await ai_image("edits", payload, idempotency_key) + + @router.get("/ai/requests/{request_id}") + async def ai_request(request_id: str): + return await call("GET", f"/v1/ai/requests/{request_id}") + + @router.post("/ai/requests/{request_id}/cancel") + async def cancel_ai_request(request_id: str): + return await call("POST", f"/v1/ai/requests/{request_id}/cancel") + + return router diff --git a/ai2apps/api/documents.py b/ai2apps/api/documents.py new file mode 100644 index 00000000..43842b35 --- /dev/null +++ b/ai2apps/api/documents.py @@ -0,0 +1,137 @@ +"""Session-scoped durable attachment APIs.""" + +from __future__ import annotations + +import base64 +import binascii +import asyncio +from typing import Any + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from ai2apps.api.errors import platform_error_response, repository_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.api.resources import _runtime_or_error +from ai2apps.core import RepositoryError + + +class AttachmentUploadRequest(BaseModel): + filename: str = Field(min_length=1, max_length=512) + media_type: str = Field( + default="application/octet-stream", min_length=1, max_length=255 + ) + data: str = Field(min_length=1) + metadata: dict[str, Any] = Field(default_factory=dict) + + +def _attachment(item) -> dict[str, Any]: + return { + "id": item.id, + "session_id": item.session_id, + "filename": item.filename, + "media_type": item.media_type, + "size_bytes": item.size_bytes, + "sha256": item.sha256, + "status": item.status.value, + "error": item.error, + "metadata": item.metadata, + "created_at": item.created_at, + } + + +def _decode(value: str) -> bytes: + payload = ( + value.split(",", 1)[1] if value.startswith("data:") and "," in value else value + ) + try: + return base64.b64decode(payload, validate=True) + except (ValueError, binascii.Error) as exc: + raise ValueError("Attachment data must be valid base64") from exc + + +def create_document_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter(prefix="/sessions/{session_id}/attachments") + + def runtime_or_error(): + return _runtime_or_error(runtime_provider) + + @router.post("", status_code=201) + async def upload(session_id: str, request: AttachmentUploadRequest): + runtime = runtime_or_error() + if isinstance(runtime, JSONResponse): + return runtime + try: + record = await asyncio.to_thread( + runtime.documents.create, + session_id, + filename=request.filename, + media_type=request.media_type, + data=_decode(request.data), + metadata=request.metadata, + ) + runtime.document_manager.enqueue(session_id, record.id) + return _attachment(record) + except RepositoryError as exc: + return repository_error_response(exc) + except ValueError as exc: + return platform_error_response( + status_code=400, code="invalid_attachment", message=str(exc) + ) + + @router.get("") + def list_attachments(session_id: str): + runtime = runtime_or_error() + if isinstance(runtime, JSONResponse): + return runtime + return { + "items": [_attachment(item) for item in runtime.documents.list(session_id)] + } + + @router.get("/{attachment_id}") + def get_attachment(session_id: str, attachment_id: str): + runtime = runtime_or_error() + if isinstance(runtime, JSONResponse): + return runtime + try: + return _attachment(runtime.documents.get(session_id, attachment_id)) + except RepositoryError as exc: + return repository_error_response(exc) + + @router.get("/{attachment_id}/blocks") + def read_blocks( + session_id: str, attachment_id: str, offset: int = 0, limit: int = 50 + ): + runtime = runtime_or_error() + if isinstance(runtime, JSONResponse): + return runtime + try: + items = runtime.documents.blocks( + session_id, attachment_id, offset=offset, limit=limit + ) + return { + "items": [ + ( + item.__dict__ + if hasattr(item, "__dict__") + else { + "id": item.id, + "ordinal": item.ordinal, + "kind": item.kind, + "text": item.text, + "page": item.page, + "section": item.section, + "sheet": item.sheet, + "slide": item.slide, + "cell_range": item.cell_range, + "metadata": item.metadata or {}, + } + ) + for item in items + ] + } + except RepositoryError as exc: + return repository_error_response(exc) + + return router diff --git a/ai2apps/api/errors.py b/ai2apps/api/errors.py new file mode 100644 index 00000000..409e9fd7 --- /dev/null +++ b/ai2apps/api/errors.py @@ -0,0 +1,102 @@ +"""Stable error envelope for AI2Apps platform APIs.""" + +from __future__ import annotations + +from typing import Any + +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from ai2apps.core import ( + IdempotencyConflictError, + RepositoryError, + ResourceConflictError, + ResourceNotFoundError, + RevisionConflictError, +) + + +class PlatformError(BaseModel): + """Machine-readable platform error detail.""" + + code: str + message: str + retryable: bool = False + details: dict[str, Any] = Field(default_factory=dict) + + +class PlatformErrorEnvelope(BaseModel): + """Top-level error response shared by AI2Apps resource APIs.""" + + error: PlatformError + + +def platform_error_response( + *, + status_code: int, + code: str, + message: str, + retryable: bool = False, + details: dict[str, Any] | None = None, +) -> JSONResponse: + """Build a JSON response using the stable platform error envelope.""" + + envelope = PlatformErrorEnvelope( + error=PlatformError( + code=code, + message=message, + retryable=retryable, + details=details or {}, + ) + ) + return JSONResponse( + status_code=status_code, + content=envelope.model_dump(mode="json"), + ) + + +def repository_error_response(error: RepositoryError) -> JSONResponse: + """Map typed Repository failures to the stable platform API envelope.""" + + if isinstance(error, ResourceNotFoundError): + return platform_error_response( + status_code=404, + code="not_found", + message=str(error), + details={ + "resource_id": error.resource_id, + "resource_type": error.resource_type, + }, + ) + if isinstance(error, RevisionConflictError): + return platform_error_response( + status_code=409, + code="revision_conflict", + message=str(error), + details={ + "actual_revision": error.actual, + "expected_revision": error.expected, + "resource_id": error.resource_id, + }, + ) + if isinstance(error, IdempotencyConflictError): + return platform_error_response( + status_code=409, + code="idempotency_conflict", + message=str(error), + details={ + "idempotency_key": error.idempotency_key, + "session_id": error.session_id, + }, + ) + if isinstance(error, ResourceConflictError): + return platform_error_response( + status_code=409, + code="resource_conflict", + message=str(error), + ) + return platform_error_response( + status_code=500, + code="repository_error", + message="Platform persistence operation failed.", + ) diff --git a/ai2apps/api/event_stream.py b/ai2apps/api/event_stream.py new file mode 100644 index 00000000..61822031 --- /dev/null +++ b/ai2apps/api/event_stream.py @@ -0,0 +1,70 @@ +"""HTTP transport for replayable AI2Apps platform Events.""" + +from __future__ import annotations + +from fastapi import APIRouter, Header, Query +from fastapi.responses import JSONResponse, StreamingResponse + +from ai2apps.api.errors import platform_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.events.stream import stream_events + + +def create_event_stream_router( + runtime_provider: PlatformRuntimeProvider, +) -> APIRouter: + router = APIRouter() + + @router.get("/events", response_model=None) + async def events( + after: int | None = Query(default=None, ge=0), + session_id: str | None = None, + app_instance_id: str | None = None, + subject_id: str | None = None, + last_event_id: str | None = Header(default=None, alias="Last-Event-ID"), + ) -> StreamingResponse | JSONResponse: + cursor = after + if cursor is None and last_event_id is not None: + try: + cursor = int(last_event_id) + except ValueError: + return platform_error_response( + status_code=400, + code="invalid_event_cursor", + message="Last-Event-ID must be a non-negative integer.", + ) + if cursor < 0: + return platform_error_response( + status_code=400, + code="invalid_event_cursor", + message="Last-Event-ID must be a non-negative integer.", + ) + runtime = runtime_provider() + if ( + runtime is None + or runtime.events is None + or runtime.notifications is None + ): + return platform_error_response( + status_code=503, + code="platform_not_ready", + message="AI2Apps Event transport is not ready.", + retryable=True, + ) + return StreamingResponse( + stream_events( + runtime.events, + runtime.notifications, + after_sequence=cursor or 0, + session_id=session_id, + app_instance_id=app_instance_id, + subject_id=subject_id, + ), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + + return router diff --git a/ai2apps/api/extensions.py b/ai2apps/api/extensions.py new file mode 100644 index 00000000..bd0e9dde --- /dev/null +++ b/ai2apps/api/extensions.py @@ -0,0 +1,434 @@ +"""M9 Agent/App/Patch package, Effective definition, App mount, and Safe Mode API.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from ai2apps.api.errors import repository_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.core import RepositoryError +from ai2apps.extensions import ExtensionError, UnitKind + + +class InstallRequest(BaseModel): + archive_path: str + approve_review: bool = False + + +class PatchCreateRequest(BaseModel): + target_kind: UnitKind + target_key: str + intent: str + operations: list[dict[str, Any]] + rebase_policy: str = "strict" + tests: list[dict[str, Any]] = Field(default_factory=list) + resources: dict[str, str] = Field(default_factory=dict) + version: str = "1.0.0" + + +class PatchResolutionRequest(BaseModel): + resolution: str + candidate_digest: str | None = None + + +class AppLaunchRequest(BaseModel): + singleton_identity: str = "local" + state: dict[str, Any] = Field(default_factory=dict) + + +class MountRequest(BaseModel): + mini: bool = False + placement: str = "entry" + interaction_session_id: str | None = None + context: dict[str, Any] = Field(default_factory=dict) + + +class SafeModeRequest(BaseModel): + active: bool + reason: str = "user-request" + + +def _error(error: ExtensionError) -> JSONResponse: + status = 409 if error.code.endswith(("conflict", "unavailable")) else 422 + return JSONResponse( + status_code=status, + content={ + "error": { + "code": error.code, + "message": str(error), + "details": error.details, + } + }, + ) + + +def _package(item): + return { + "id": item.id, + "kind": item.kind.value, + "key": item.unit_key, + "version": item.version, + "digest": item.digest, + "publisher": item.publisher_key, + "status": item.status.value, + "manifest": item.manifest, + "file_index": item.file_index, + "sbom": item.sbom, + "verification": item.verification, + } + + +def _effective(item): + return { + "id": item.id, + "kind": item.kind.value, + "key": item.unit_key, + "upstream_digest": item.upstream_digest, + "patch_set_digest": item.patch_set_digest, + "effective_digest": item.effective_digest, + "effective_version": item.effective_version, + "manifest": item.manifest, + "resources": item.resources, + "audit": item.audit, + "status": item.status, + } + + +def create_extension_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter(tags=["interactive-packages"]) + + def runtime(): + value = runtime_provider() + if value is None or value.extension_manager is None: + return JSONResponse( + status_code=503, content={"error": {"code": "platform_unavailable"}} + ) + return value + + @router.post("/interactive-packages/inspect") + def inspect(request: InstallRequest): + value = runtime() + if isinstance(value, JSONResponse): + return value + try: + bundle, verification = value.extension_manager.inspect(request.archive_path) + return { + "kind": str(bundle.kind), + "key": bundle.key, + "version": bundle.version, + "digest": bundle.digest, + "manifest": bundle.manifest, + "files": [ + item.__dict__ + if hasattr(item, "__dict__") + else { + "path": item.path, + "content_hash": item.content_hash, + "size_bytes": item.size_bytes, + } + for item in bundle.files + ], + "sbom": bundle.sbom, + "verification": verification, + } + except ExtensionError as error: + return _error(error) + + @router.post("/interactive-packages/install") + async def install(request: InstallRequest): + value = runtime() + if isinstance(value, JSONResponse): + return value + try: + item = await value.extension_manager.install( + request.archive_path, approve_review=request.approve_review + ) + if hasattr(item, "unit_key"): + return _package(item) + return { + "id": item.id, + "kind": item.target_kind.value, + "key": item.target_key, + "digest": item.digest, + "status": item.status.value, + } + except ExtensionError as error: + return _error(error) + except RepositoryError as error: + return repository_error_response(error) + + @router.get("/interactive-packages") + def packages(kind: UnitKind | None = None, key: str | None = None): + value = runtime() + if isinstance(value, JSONResponse): + return value + return { + "items": [ + _package(item) + for item in value.extension_repository.installed(kind, key) + ] + } + + @router.get("/interactive-operations") + def operations(kind: UnitKind | None = None, key: str | None = None): + value = runtime() + if isinstance(value, JSONResponse): + return value + return {"items": value.extension_repository.operations(kind, key)} + + @router.post("/interactive-packages/{digest:path}/activate") + def activate(digest: str): + value = runtime() + if isinstance(value, JSONResponse): + return value + try: + return _package( + value.extension_manager.activate_candidate( + "sha256:" + digest.removeprefix("sha256:") + ) + ) + except ExtensionError as error: + return _error(error) + + @router.get("/effective-definitions/{kind}/{key}") + def effective(kind: UnitKind, key: str): + value = runtime() + if isinstance(value, JSONResponse): + return value + item = value.extension_repository.effective(kind, key) + if item is None: + return JSONResponse( + status_code=404, + content={"error": {"code": "effective_definition_not_found"}}, + ) + return _effective(item) + + @router.get("/local-patches/{kind}/{key}") + def patches(kind: UnitKind, key: str): + value = runtime() + if isinstance(value, JSONResponse): + return value + return { + "items": [ + { + "id": item.id, + "digest": item.digest, + "base_digest": item.base_digest, + "intent": item.intent, + "rebase_policy": item.rebase_policy.value, + "operations": item.operations, + "tests": item.tests, + "audit": item.audit, + "status": item.status.value, + "conflict": item.conflict, + "stack_order": item.stack_order, + } + for item in value.extension_repository.patches(kind, key) + ] + } + + @router.post("/local-patches/create") + def create_patch(request: PatchCreateRequest): + value = runtime() + if isinstance(value, JSONResponse): + return value + try: + exports = value.config.paths.packages_path / "exports" + filename = f"{request.target_key}-{request.version}.ai2patch" + path = value.extension_manager.create_patch( + exports / filename, + target_kind=request.target_kind, + target_key=request.target_key, + intent=request.intent, + operations=request.operations, + rebase_policy=request.rebase_policy, + tests=request.tests, + resources=request.resources, + version=request.version, + ) + return {"archive_path": str(path)} + except ExtensionError as error: + return _error(error) + + @router.post("/local-patches/{patch_id}/resolve") + def resolve(patch_id: str, request: PatchResolutionRequest): + value = runtime() + if isinstance(value, JSONResponse): + return value + try: + item = value.extension_manager.resolve_patch( + patch_id, request.resolution, candidate_digest=request.candidate_digest + ) + return { + "id": item.id, + "status": item.status.value, + "base_digest": item.base_digest, + } + except ExtensionError as error: + return _error(error) + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/apps/{app_key}/launch") + def launch(app_key: str, request: AppLaunchRequest): + value = runtime() + if isinstance(value, JSONResponse): + return value + try: + instance, home, created = value.extension_manager.launch_app( + app_key, + singleton_identity=request.singleton_identity, + state=request.state, + ) + return { + "created": created, + "instance_id": instance.id, + "home_session_id": None if home is None else home.id, + "state": instance.state, + "state_schema_version": instance.state_schema_version, + "entry_url": f"/apps/{app_key}/instances/{instance.id}", + } + except ExtensionError as error: + return _error(error) + except RepositoryError as error: + return repository_error_response(error) + + @router.get("/apps") + def apps(): + value = runtime() + if isinstance(value, JSONResponse): + return value + return {"items": value.extension_manager.list_apps()} + + @router.get("/app-instances/{instance_id}/entry") + def instance_entry(instance_id: str): + value = runtime() + if isinstance(value, JSONResponse): + return value + try: + return value.extension_manager.instance_entry(instance_id) + except ExtensionError as error: + return _error(error) + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/app-instances/{instance_id}/focus") + def focus(instance_id: str): + value = runtime() + if isinstance(value, JSONResponse): + return value + try: + instance = value.extension_manager.focus_instance(instance_id) + return {"instance_id": instance.id, "status": instance.status.value} + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/app-instances/{instance_id}/suspend") + def suspend(instance_id: str): + value = runtime() + if isinstance(value, JSONResponse): + return value + try: + instance = value.extension_manager.suspend_instance(instance_id) + return {"instance_id": instance.id, "status": instance.status.value} + except RepositoryError as error: + return repository_error_response(error) + + @router.delete("/app-instances/{instance_id}") + def close(instance_id: str): + value = runtime() + if isinstance(value, JSONResponse): + return value + try: + instance = value.extension_manager.close_instance(instance_id) + return {"instance_id": instance.id, "status": instance.status.value} + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/app-instances/{instance_id}/mounts") + def mount(instance_id: str, request: MountRequest): + value = runtime() + if isinstance(value, JSONResponse): + return value + try: + return value.extension_manager.mount( + instance_id, + mini=request.mini, + placement=request.placement, + interaction_session_id=request.interaction_session_id, + context=request.context, + ) + except ExtensionError as error: + return _error(error) + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/definitions/{kind}/{key}/enable") + def enable(kind: UnitKind, key: str): + value = runtime() + if isinstance(value, JSONResponse): + return value + try: + value.extension_manager.set_enabled(kind, key, True) + return {"status": "enabled"} + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/definitions/{kind}/{key}/disable") + def disable(kind: UnitKind, key: str): + value = runtime() + if isinstance(value, JSONResponse): + return value + try: + value.extension_manager.set_enabled(kind, key, False) + return {"status": "disabled"} + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/definitions/{kind}/{key}/rollback") + def rollback(kind: UnitKind, key: str): + value = runtime() + if isinstance(value, JSONResponse): + return value + try: + return _package(value.extension_manager.rollback(kind, key)) + except ExtensionError as error: + return _error(error) + except RepositoryError as error: + return repository_error_response(error) + + @router.delete("/definitions/{kind}/{key}") + def uninstall(kind: UnitKind, key: str, force: bool = False): + value = runtime() + if isinstance(value, JSONResponse): + return value + try: + value.extension_manager.uninstall(kind, key, force=force) + return {"status": "uninstalled"} + except ExtensionError as error: + return _error(error) + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/safe-mode") + async def safe_mode(request: SafeModeRequest): + value = runtime() + if isinstance(value, JSONResponse): + return value + try: + return await value.set_safe_mode(request.active, request.reason) + except ExtensionError as error: + return _error(error) + + @router.get("/safe-mode") + def safe_mode_status(): + value = runtime() + if isinstance(value, JSONResponse): + return value + return value.extension_manager.safe_mode_status() + + return router diff --git a/ai2apps/api/health.py b/ai2apps/api/health.py new file mode 100644 index 00000000..8a73b092 --- /dev/null +++ b/ai2apps/api/health.py @@ -0,0 +1,90 @@ +"""Health contract for the AI2Apps Harness backend.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Literal + +from fastapi import APIRouter +from pydantic import BaseModel + +from ai2apps import __version__ +from ai2apps.config import PlatformConfig +from ai2apps.platform_runtime import PlatformDatabaseStatus, PlatformRuntime + +PlatformConfigProvider = Callable[[], PlatformConfig] +PlatformRuntimeProvider = Callable[[], PlatformRuntime | None] + + +class RuntimeHealth(BaseModel): + """Runtime adapter attached to the platform API.""" + + provider: str + attached: bool + + +class DatabaseHealth(BaseModel): + """Platform database bootstrap state.""" + + configured: bool + status: Literal["unconfigured", "not_initialized", "ready"] + schema_version: int + target_schema_version: int + filename: str + journal_mode: str | None = None + + +class PlatformHealthResponse(BaseModel): + """Versioned health response for the AI2Apps platform layer.""" + + status: Literal["ok"] + product: Literal["ai2apps"] + version: str + api_version: Literal["v1"] + runtime: RuntimeHealth + database: DatabaseHealth + + +def _unconfigured_platform() -> PlatformConfig: + return PlatformConfig.unconfigured() + + +def create_health_router( + config_provider: PlatformConfigProvider | None = None, + runtime_provider: PlatformRuntimeProvider | None = None, +) -> APIRouter: + """Create the health router without importing the embedded oMLX runtime.""" + + router = APIRouter() + provide_config = config_provider or _unconfigured_platform + + def database_status() -> PlatformDatabaseStatus: + runtime = runtime_provider() if runtime_provider is not None else None + if runtime is not None: + return runtime.database_status + return PlatformRuntime.status_before_start(provide_config()) + + @router.get( + "/health", + response_model=PlatformHealthResponse, + summary="Get AI2Apps platform health", + ) + async def platform_health() -> PlatformHealthResponse: + database = database_status() + return PlatformHealthResponse( + status="ok", + product="ai2apps", + version=__version__, + api_version="v1", + runtime=RuntimeHealth(provider="omlx", attached=True), + database=DatabaseHealth( + configured=database.configured, + status=database.status, + schema_version=database.schema_version, + target_schema_version=database.target_schema_version, + filename=database.filename, + journal_mode=database.journal_mode, + ), + ) + + return router diff --git a/ai2apps/api/models.py b/ai2apps/api/models.py new file mode 100644 index 00000000..8fa027a5 --- /dev/null +++ b/ai2apps/api/models.py @@ -0,0 +1,216 @@ +"""Request and response contracts for Session, Message, and Event APIs.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field, model_validator + +from ai2apps.core import ( + MessageRole, + MessageStatus, + SessionKind, + SessionRetention, + SessionStatus, + SessionVisibility, +) +from ai2apps.storage.models import EventRecord, MessageWithParts, SessionRecord + + +class SessionCreateRequest(BaseModel): + title: str = "" + is_home: bool = False + kind: SessionKind = SessionKind.APP + visibility: SessionVisibility | None = None + retention: SessionRetention | None = None + expires_at: datetime | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="after") + def expiry_matches_effective_retention(self) -> SessionCreateRequest: + embedded_chat = self.kind in { + SessionKind.MINI_CHAT, + SessionKind.IN_APP_CHAT, + } + effective_retention = self.retention or ( + SessionRetention.TEMPORARY + if embedded_chat + else SessionRetention.DURABLE + ) + if ( + self.expires_at is not None + and effective_retention is SessionRetention.DURABLE + ): + raise ValueError("expires_at is valid only for temporary Sessions") + return self + + +class SessionPatchRequest(BaseModel): + expected_revision: int = Field(ge=1) + title: str | None = None + status: SessionStatus | None = None + is_home: bool | None = None + visibility: SessionVisibility | None = None + retention: SessionRetention | None = None + metadata: dict[str, Any] | None = None + + @model_validator(mode="after") + def require_change(self) -> SessionPatchRequest: + if all( + value is None + for value in ( + self.title, + self.status, + self.is_home, + self.visibility, + self.retention, + self.metadata, + ) + ): + raise ValueError("At least one Session field must change") + return self + + +class SessionResponse(BaseModel): + id: str + app_instance_id: str + title: str + status: SessionStatus + is_home: bool + kind: SessionKind + visibility: SessionVisibility + retention: SessionRetention + revision: int + metadata: dict[str, Any] + created_at: datetime + updated_at: datetime + archived_at: datetime | None + deleted_at: datetime | None + expires_at: datetime | None + + @classmethod + def from_record(cls, record: SessionRecord) -> SessionResponse: + return cls( + id=record.id, + app_instance_id=record.app_instance_id, + title=record.title, + status=record.status, + is_home=record.is_home, + kind=record.session_kind, + visibility=record.visibility, + retention=record.retention, + revision=record.revision, + metadata=record.metadata, + created_at=record.created_at, + updated_at=record.updated_at, + archived_at=record.archived_at, + deleted_at=record.deleted_at, + expires_at=record.expires_at, + ) + + +class SessionListResponse(BaseModel): + items: list[SessionResponse] + + +class MessagePartRequest(BaseModel): + kind: str = Field(min_length=1) + content: dict[str, Any] + + +class MessageCreateRequest(BaseModel): + role: MessageRole + parts: list[MessagePartRequest] = Field(min_length=1) + status: MessageStatus = MessageStatus.COMPLETED + idempotency_key: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class MessagePartResponse(BaseModel): + id: str + position: int + kind: str + content: dict[str, Any] + created_at: datetime + + +class MessageResponse(BaseModel): + id: str + session_id: str + sequence: int + role: MessageRole + status: MessageStatus + idempotency_key: str | None + metadata: dict[str, Any] + created_at: datetime + updated_at: datetime + parts: list[MessagePartResponse] + created: bool = True + + @classmethod + def from_record( + cls, + value: MessageWithParts, + *, + created: bool = True, + ) -> MessageResponse: + message = value.message + return cls( + id=message.id, + session_id=message.session_id, + sequence=message.sequence, + role=message.role, + status=message.status, + idempotency_key=message.idempotency_key, + metadata=message.metadata, + created_at=message.created_at, + updated_at=message.updated_at, + parts=[ + MessagePartResponse( + id=part.id, + position=part.position, + kind=part.kind, + content=part.content, + created_at=part.created_at, + ) + for part in value.parts + ], + created=created, + ) + + +class MessageListResponse(BaseModel): + items: list[MessageResponse] + + +class EventResponse(BaseModel): + id: str + sequence: int + type: str + occurred_at: datetime + app_instance_id: str | None + session_id: str | None + subject_id: str + trace_id: str | None + schema_version: int + payload: dict[str, Any] + + @classmethod + def from_record(cls, event: EventRecord) -> EventResponse: + return cls( + id=event.id, + sequence=event.sequence, + type=event.type, + occurred_at=event.occurred_at, + app_instance_id=event.app_instance_id, + session_id=event.session_id, + subject_id=event.subject_id, + trace_id=event.trace_id, + schema_version=event.schema_version, + payload=event.payload, + ) + + +class EventListResponse(BaseModel): + items: list[EventResponse] diff --git a/ai2apps/api/packages.py b/ai2apps/api/packages.py new file mode 100644 index 00000000..d1b1b734 --- /dev/null +++ b/ai2apps/api/packages.py @@ -0,0 +1,718 @@ +"""Trusted Service package, publisher, audit, logs, and lifecycle APIs.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Query +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from ai2apps.api.errors import platform_error_response, repository_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.core import RepositoryError +from ai2apps.packages import PackageError, TrustStatus +from ai2apps.packages.contract_v1 import PackageContractError +from ai2apps.packages.registry import RegistryError + + +class PublisherRequest(BaseModel): + display_name: str = Field(min_length=1) + key_id: str = Field(min_length=1) + public_key: str = Field(min_length=1) + trust_status: TrustStatus + source: str = "user" + metadata: dict[str, Any] = Field(default_factory=dict) + + +class PackageInspectRequest(BaseModel): + archive_path: str = Field(min_length=1) + + +class PackageInstallRequest(PackageInspectRequest): + dependency_archives: list[str] = Field(default_factory=list) + allow_untrusted: bool = False + approve_audit_review: bool = False + + +class RegistryBuildRequest(BaseModel): + source_path: str = Field(min_length=1) + output_path: str = Field(min_length=1) + + +class PublisherKeyCreateRequest(BaseModel): + name: str = Field(min_length=1, max_length=120) + + +class RegistrySignRequest(BaseModel): + archive_path: str = Field(min_length=1) + key_ref: str = Field(min_length=1) + publisher_id: str = Field(min_length=1) + publisher_key_id: str = Field(min_length=1) + + +class PublisherKeyProofRequest(BaseModel): + key_ref: str = Field(min_length=1) + payload: dict[str, Any] + + +class RegistryInstallRequest(BaseModel): + version: str | None = None + approve_review: bool = False + + +class RegistryUninstallRequest(BaseModel): + force: bool = False + + +class CloudPublisherCreateRequest(BaseModel): + display_name: str = Field(min_length=1, max_length=160) + namespace: str = Field(min_length=3, max_length=80) + kind: str = Field(default="personal", pattern="^(personal|organization)$") + + +class CloudKeyChallengeRequest(BaseModel): + key_ref: str = Field(min_length=1) + + +class CloudKeyRegisterRequest(BaseModel): + challenge_id: str = Field(min_length=1) + signature: str = Field(min_length=1) + + +class CloudSubmissionRequest(BaseModel): + archive_path: str = Field(min_length=1) + envelope: dict[str, Any] + + +class CloudSubmissionReviewRequest(BaseModel): + decision: str = Field(pattern="^(approved|rejected)$") + note: str = Field(min_length=1, max_length=2000) + + +def _package(record) -> dict[str, Any]: + return { + "id": record.id, + "service_key": record.service_key, + "version": record.package_version, + "digest": record.package_digest, + "publisher": record.publisher_key, + "runtime_mode": record.runtime_mode.value, + "protocol": record.protocol, + "status": record.status.value, + "permissions": record.permissions, + "compatibility": record.compatibility, + "verification": record.verification, + "store_path": record.store_path, + "installed_at": record.installed_at.isoformat(), + "activated_at": None + if record.activated_at is None + else record.activated_at.isoformat(), + } + + +def _error(error: PackageError) -> JSONResponse: + status = { + "archive_not_found": 404, + "publisher_unknown": 403, + "publisher_untrusted": 403, + "publisher_revoked": 403, + "signature_invalid": 403, + "audit_rejected": 403, + "audit_review_required": 409, + "dependency_unresolved": 409, + "dependency_conflict": 409, + "dependency_cycle": 409, + "service_has_dependents": 409, + "platform_incompatible": 422, + "accelerator_incompatible": 422, + }.get(error.code, 422) + return platform_error_response( + status_code=status, + code=error.code, + message=str(error), + details=error.details, + ) + + +def _registry_error(error: RegistryError | PackageContractError) -> JSONResponse: + status = { + "release_not_found": 404, + "package_not_installed": 404, + "repository_key_unpinned": 403, + "repository_signature_invalid": 403, + "publisher_signature_invalid": 403, + "release_unavailable": 409, + "repository_metadata_rollback": 409, + "repository_metadata_expired": 503, + "audit_review_required": 409, + "app_has_instances": 409, + "platform_incompatible": 422, + "architecture_incompatible": 422, + "ai2apps_incompatible": 422, + "service_contract_adapter_required": 501, + }.get(error.code) + if status is None and isinstance(error, RegistryError): + upstream_status = error.details.get("status") + status = upstream_status if upstream_status in {400, 401, 403, 404, 409, 413, 422, 429, 503} else None + status = status or 422 + return platform_error_response( + status_code=status, + code=error.code, + message=str(error), + details=error.details, + ) + + +def create_package_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter() + + def runtime_or_error(): + runtime = runtime_provider() + if ( + runtime is None + or runtime.package_repository is None + or runtime.package_manager is None + ): + return platform_error_response( + status_code=503, + code="platform_not_ready", + message="AI2Apps package runtime is not ready.", + retryable=True, + ) + return runtime + + def registry_or_error(): + runtime = runtime_provider() + if runtime is None or runtime.registry_packages is None: + return platform_error_response( + status_code=503, + code="platform_not_ready", + message="AI2Apps Registry package runtime is not ready.", + retryable=True, + ) + return runtime.registry_packages + + @router.get("/packages/catalog/search") + async def registry_search( + q: str = "", + type: str | None = Query(default=None, pattern="^(app|agent|service)$"), + publisher: str | None = None, + sort: str = Query(default="recommended", pattern="^(recommended|relevance|rating|newest)$"), + limit: int = Query(default=24, ge=1, le=100), + cursor: str | None = None, + ): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + return await manager.search(q=q, type=type, publisher=publisher, sort=sort, limit=limit, cursor=cursor) + except RegistryError as error: + return _registry_error(error) + + @router.get("/packages/catalog/recommendations") + async def registry_recommendations( + type: str | None = Query(default=None, pattern="^(app|agent|service)$"), + limit: int = Query(default=24, ge=1, le=100), + cursor: str | None = None, + ): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + return await manager.recommendations(type=type, limit=limit, cursor=cursor) + except RegistryError as error: + return _registry_error(error) + + @router.get("/packages/catalog/{namespace}/{name}") + async def registry_catalog(namespace: str, name: str): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + return await manager.catalog(namespace, name) + except RegistryError as error: + return _registry_error(error) + + @router.get("/packages/installed") + def registry_installed(): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + return {"items": manager.installed()} + + @router.post("/packages/build") + def registry_build(request: RegistryBuildRequest): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + item = manager.build(request.source_path, request.output_path) + return { + "archivePath": str(item.archive_path), + "package": item.manifest["package"], + "sha256": item.sha256, + "size": item.size, + "mediaType": item.media_type, + "manifestSha256": item.manifest_sha256, + } + except (RegistryError, PackageContractError) as error: + return _registry_error(error) + + @router.post("/packages/inspect") + def registry_inspect(request: PackageInspectRequest): + from ai2apps.packages.contract_v1 import inspect_package + + try: + item = inspect_package(request.archive_path) + return { + "archivePath": str(item.archive_path), + "manifest": item.manifest, + "sha256": item.sha256, + "size": item.size, + "mediaType": item.media_type, + "manifestSha256": item.manifest_sha256, + } + except PackageContractError as error: + return _registry_error(error) + + @router.post("/packages/publisher-keys") + def registry_create_key(request: PublisherKeyCreateRequest): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + return manager.create_key(request.name) + except (RegistryError, ValueError) as error: + if isinstance(error, RegistryError): + return _registry_error(error) + return platform_error_response(status_code=422, code="publisher_key_invalid", message=str(error)) + + @router.get("/packages/publisher-keys") + def registry_keys(): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + return manager.keys() + + @router.post("/packages/publisher-keys/proof") + def registry_key_proof(request: PublisherKeyProofRequest): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + return {"signature": manager.key_proof(request.payload, request.key_ref)} + except (RegistryError, PackageContractError) as error: + return _registry_error(error) + + @router.post("/packages/sign") + def registry_sign(request: RegistrySignRequest): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + return manager.sign( + request.archive_path, + request.key_ref, + request.publisher_id, + request.publisher_key_id, + ) + except (RegistryError, PackageContractError) as error: + return _registry_error(error) + + @router.get("/packages/publishing/publishers") + async def registry_publishers(): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + return await manager.publishers() + except RegistryError as error: + return _registry_error(error) + + @router.post("/packages/publishing/publishers") + async def registry_create_publisher(request: CloudPublisherCreateRequest): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + return await manager.create_publisher(request.display_name, request.namespace, request.kind) + except RegistryError as error: + return _registry_error(error) + + @router.post("/packages/publishing/publishers/{publisher_id}/key-challenges") + async def registry_create_key_challenge(publisher_id: str, request: CloudKeyChallengeRequest): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + challenge = await manager.create_key_challenge(publisher_id, request.key_ref) + challenge["proofSignature"] = manager.key_proof(challenge["proofPayload"], request.key_ref) + return challenge + except (RegistryError, PackageContractError) as error: + return _registry_error(error) + + @router.post("/packages/publishing/publishers/{publisher_id}/keys") + async def registry_register_key(publisher_id: str, request: CloudKeyRegisterRequest): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + return await manager.register_key(publisher_id, request.challenge_id, request.signature) + except RegistryError as error: + return _registry_error(error) + + @router.post("/packages/publishing/submissions") + async def registry_submit(request: CloudSubmissionRequest): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + return await manager.submit(request.archive_path, request.envelope) + except (RegistryError, PackageContractError) as error: + return _registry_error(error) + + @router.get("/packages/publishing/submissions") + async def registry_submissions( + status: str | None = None, + limit: int = Query(default=50, ge=1, le=100), + ): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + return await manager.submissions(status=status, limit=limit) + except RegistryError as error: + return _registry_error(error) + + @router.get("/packages/publishing/submissions/{submission_id}") + async def registry_submission(submission_id: str): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + return await manager.submission(submission_id) + except RegistryError as error: + return _registry_error(error) + + @router.get("/packages/publishing/submissions/{submission_id}/details") + async def registry_submission_details(submission_id: str): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + return await manager.submission_details(submission_id) + except RegistryError as error: + return _registry_error(error) + + @router.post("/packages/publishing/submissions/{submission_id}/review-request") + async def registry_request_review(submission_id: str): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + return await manager.request_review(submission_id) + except RegistryError as error: + return _registry_error(error) + + @router.post("/packages/publishing/submissions/{submission_id}/reviews") + async def registry_review_submission( + submission_id: str, request: CloudSubmissionReviewRequest + ): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + return await manager.review_submission( + submission_id, request.decision, request.note + ) + except RegistryError as error: + return _registry_error(error) + + @router.post("/packages/publishing/submissions/{submission_id}/publication") + async def registry_publish_submission(submission_id: str): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + return await manager.publish_submission(submission_id) + except RegistryError as error: + return _registry_error(error) + + @router.post("/packages/{namespace}/{name}/download") + async def registry_download(namespace: str, name: str, request: RegistryInstallRequest): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + item, _envelope, release, metadata_version = await manager.download_verified(namespace, name, request.version) + return { + "archivePath": str(item.archive_path), + "package": item.manifest["package"], + "sha256": item.sha256, + "size": item.size, + "repositoryMetadataVersion": metadata_version, + "publisher": release["publisher"], + "verified": True, + } + except RegistryError as error: + return _registry_error(error) + + @router.post("/packages/{namespace}/{name}/install") + async def registry_install(namespace: str, name: str, request: RegistryInstallRequest): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + item = await manager.install( + namespace, + name, + request.version, + approve_review=request.approve_review, + ) + if hasattr(item, "unit_key"): + package_id = item.unit_key + package_type = item.kind.value + version = item.version + digest = item.digest + else: + package_id = f"{namespace}/{name}" + package_type = "service" + version = item.package_version + digest = item.package_digest + return { + "packageId": package_id, + "packageType": package_type, + "version": version, + "digest": digest, + "status": item.status.value, + } + except RegistryError as error: + return _registry_error(error) + + @router.post("/packages/{namespace}/{name}/uninstall") + async def registry_uninstall(namespace: str, name: str, request: RegistryUninstallRequest): + manager = registry_or_error() + if isinstance(manager, JSONResponse): + return manager + try: + await manager.uninstall(f"{namespace}/{name}", force=request.force) + return {"packageId": f"{namespace}/{name}", "status": "uninstalled"} + except RegistryError as error: + return _registry_error(error) + + @router.get("/publishers") + def list_publishers(): + runtime = runtime_or_error() + if isinstance(runtime, JSONResponse): + return runtime + return { + "items": [ + { + "publisher_key": item.publisher_key, + "display_name": item.display_name, + "key_id": item.key_id, + "algorithm": item.algorithm, + "public_key": item.public_key, + "trust_status": item.trust_status.value, + "source": item.source, + "metadata": item.metadata, + "revision": item.revision, + } + for item in runtime.package_repository.list_publishers() + ] + } + + @router.put("/publishers/{publisher_key}") + def put_publisher(publisher_key: str, request: PublisherRequest): + runtime = runtime_or_error() + if isinstance(runtime, JSONResponse): + return runtime + try: + item = runtime.package_repository.upsert_publisher( + publisher_key=publisher_key, + display_name=request.display_name, + key_id=request.key_id, + public_key=request.public_key, + trust_status=request.trust_status, + source=request.source, + metadata=request.metadata, + ) + return { + "publisher_key": item.publisher_key, + "trust_status": item.trust_status.value, + "revision": item.revision, + } + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/service-packages/inspect") + def inspect_package(request: PackageInspectRequest): + runtime = runtime_or_error() + if isinstance(runtime, JSONResponse): + return runtime + try: + item = runtime.package_manager.inspect(Path(request.archive_path)) + return { + "service_key": item.manifest.service_key, + "name": item.manifest.name, + "version": item.manifest.version, + "digest": item.digest, + "publisher": item.manifest.publisher_key, + "runtime_mode": item.manifest.runtime_mode.value, + "permissions": item.manifest.permissions, + "compatibility": item.manifest.compatibility, + "files": [ + { + "path": file.path, + "hash": file.content_hash, + "size": file.size_bytes, + } + for file in item.files + ], + "sbom": item.sbom, + } + except PackageError as error: + return _error(error) + + @router.post("/service-packages/audit") + async def audit_package(request: PackageInspectRequest): + runtime = runtime_or_error() + if isinstance(runtime, JSONResponse): + return runtime + try: + return await runtime.package_manager.audit(request.archive_path) + except PackageError as error: + return _error(error) + + @router.get("/service-packages") + def list_packages(): + runtime = runtime_or_error() + if isinstance(runtime, JSONResponse): + return runtime + return { + "items": [_package(item) for item in runtime.package_repository.installed()] + } + + @router.get("/service-packages/{digest:path}") + def get_package(digest: str): + runtime = runtime_or_error() + if isinstance(runtime, JSONResponse): + return runtime + digest = digest if digest.startswith("sha256:") else f"sha256:{digest}" + try: + item = runtime.package_repository.get_by_digest(digest) + return { + **_package(item), + "manifest": item.manifest, + "sbom": item.sbom, + "files": list(runtime.package_repository.files(digest)), + "attestations": [ + { + "id": value.id, + "kind": value.kind, + "issuer": value.issuer, + "decision": value.decision.value, + "risk": value.risk.value, + "model": value.model, + "policy_version": value.policy_version, + "evidence": value.evidence, + "created_at": value.created_at.isoformat(), + } + for value in runtime.package_repository.attestations(digest) + ], + "dependency_locks": [ + { + "dependency_key": lock.dependency_key, + "version": lock.dependency_version, + "digest": lock.dependency_digest, + "optional": lock.optional, + } + for lock in runtime.package_repository.locks(digest) + ], + } + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/service-packages/install") + async def install_package(request: PackageInstallRequest): + runtime = runtime_or_error() + if isinstance(runtime, JSONResponse): + return runtime + try: + item = await runtime.package_manager.install( + request.archive_path, + dependency_archives=tuple(request.dependency_archives), + allow_untrusted=request.allow_untrusted, + approve_audit_review=request.approve_audit_review, + ) + return _package(item) + except PackageError as error: + return _error(error) + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/services/{service_key}/rollback") + async def rollback(service_key: str): + runtime = runtime_or_error() + if isinstance(runtime, JSONResponse): + return runtime + try: + return _package(await runtime.package_manager.rollback(service_key)) + except PackageError as error: + return _error(error) + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/services/{service_key}/start") + async def start_service(service_key: str): + runtime = runtime_or_error() + if isinstance(runtime, JSONResponse): + return runtime + try: + await runtime.package_manager.start(service_key) + return {"status": "running", "service_key": service_key} + except PackageError as error: + return _error(error) + except RepositoryError as error: + return repository_error_response(error) + + @router.post("/services/{service_key}/stop") + async def stop_service(service_key: str): + runtime = runtime_or_error() + if isinstance(runtime, JSONResponse): + return runtime + try: + await runtime.package_manager.stop(service_key) + return {"status": "stopped", "service_key": service_key} + except PackageError as error: + return _error(error) + except RepositoryError as error: + return repository_error_response(error) + + @router.delete("/services/{service_key}/package") + async def uninstall(service_key: str): + runtime = runtime_or_error() + if isinstance(runtime, JSONResponse): + return runtime + try: + await runtime.package_manager.uninstall(service_key) + return {"status": "uninstalled", "service_key": service_key} + except PackageError as error: + return _error(error) + except RepositoryError as error: + return repository_error_response(error) + + @router.get("/services/{service_key}/logs") + def service_logs(service_key: str, after: int = 0, limit: int = 200): + runtime = runtime_or_error() + if isinstance(runtime, JSONResponse): + return runtime + return { + "items": runtime.package_repository.logs( + service_key, after=max(0, after), limit=min(1000, max(1, limit)) + ) + } + + return router diff --git a/ai2apps/api/remote.py b/ai2apps/api/remote.py new file mode 100644 index 00000000..843fc053 --- /dev/null +++ b/ai2apps/api/remote.py @@ -0,0 +1,132 @@ +"""Local control API for AI2Apps Remote Access v1.""" + +from __future__ import annotations + +import base64 +import io +from dataclasses import asdict + +import httpx +import qrcode +import qrcode.image.svg +from fastapi import APIRouter +from fastapi.responses import JSONResponse, Response +from pydantic import BaseModel, Field + +from ai2apps.api.errors import platform_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.remote import RemoteAccessError + + +class RegisterRemoteDeviceRequest(BaseModel): + display_name: str = Field(alias="displayName", min_length=1, max_length=120) + + +def _pairing_qr_data_url(value: str) -> str: + qr = qrcode.QRCode( + error_correction=qrcode.constants.ERROR_CORRECT_Q, + box_size=8, + border=4, + ) + qr.add_data(value) + qr.make(fit=True) + image = qr.make_image(image_factory=qrcode.image.svg.SvgPathImage) + output = io.BytesIO() + image.save(output) + encoded = base64.b64encode(output.getvalue()).decode("ascii") + return f"data:image/svg+xml;base64,{encoded}" + + +def _device(value) -> dict: + result = asdict(value) + return { + "deviceId": result["device_id"], "displayName": result["display_name"], + "platform": result["platform"], "clientVersion": result["client_version"], + "status": result["status"], "suspensionReason": result["suspension_reason"], + "accessEpoch": result["access_epoch"], "publicOrigin": result["public_origin"], + "credentialVersion": result["credential_version"], + "credentialExpiresAt": result["credential_expires_at"].isoformat(), + "serverAddr": result["server_addr"], "serverPort": result["server_port"], + "proxyName": result["proxy_name"], "subdomain": result["subdomain"], + "enabled": result["enabled"], "online": result["online"], + "proxyConnected": result["proxy_connected"], + "lastSeenAt": None if result["last_seen_at"] is None else result["last_seen_at"].isoformat(), + "createdAt": result["created_at"].isoformat(), "updatedAt": result["updated_at"].isoformat(), + } + + +def create_remote_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter(prefix="/remote", tags=["platform-remote"]) + + def manager(): + runtime = runtime_provider() + value = None if runtime is None else getattr(runtime, "remote", None) + if value is None: + raise RemoteAccessError(503, "remote_not_ready", "Remote Access is not ready") + return value + + async def run(operation): + try: + return await operation + except RemoteAccessError as error: + return platform_error_response( + status_code=error.status_code, code=error.code.lower(), message=str(error), + retryable=error.status_code >= 500 or error.status_code == 429, + ) + except httpx.TimeoutException: + return platform_error_response(status_code=504, code="cloud_timeout", message="AI2Apps Cloud did not respond in time", retryable=True) + except httpx.HTTPError: + return platform_error_response(status_code=502, code="cloud_unavailable", message="AI2Apps Cloud is unavailable", retryable=True) + + @router.get("/status") + async def status(): + value = manager() + return {"devices": [_device(item) for item in value.repository.list()], + "connector": value.frpc.status()} + + @router.post("/devices") + async def register(request: RegisterRemoteDeviceRequest): + result = await run(manager().register(display_name=request.display_name)) + return result if isinstance(result, Response) else _device(result) + + @router.post("/devices/reconcile") + async def reconcile(): + result = await run(manager().reconcile()) + return result if isinstance(result, Response) else {"devices": [_device(item) for item in result]} + + @router.post("/devices/{device_id}/credentials/rotate") + async def rotate(device_id: str): + result = await run(manager().rotate(device_id)) + return result if isinstance(result, Response) else _device(result) + + @router.post("/devices/{device_id}/pairing-challenges") + async def pairing(device_id: str): + result = await run(manager().pairing_challenge(device_id)) + if isinstance(result, Response): + return result + return {**result, "pairingQrDataUrl": _pairing_qr_data_url(result["pairingUrl"])} + + @router.post("/devices/{device_id}/revoke") + async def revoke(device_id: str): + return await run(manager().revoke(device_id)) + + @router.post("/devices/{device_id}/start") + async def start(device_id: str): + result = await run(manager().start(device_id)) + return result if isinstance(result, Response) else _device(result) + + @router.post("/devices/{device_id}/stop") + async def stop(device_id: str): + result = await run(manager().stop(device_id)) + return result if isinstance(result, Response) else _device(result) + + @router.delete("/devices/{device_id}", status_code=204) + async def redact(device_id: str): + result = await run(manager().redact(device_id)) + return result if isinstance(result, Response) else Response(status_code=204) + + @router.get("/usage") + async def usage(): + return await run(manager().usage()) + + return router diff --git a/ai2apps/api/resources.py b/ai2apps/api/resources.py new file mode 100644 index 00000000..0eef0a4b --- /dev/null +++ b/ai2apps/api/resources.py @@ -0,0 +1,298 @@ +"""Generic Session, Message, and Event snapshot APIs.""" + +from __future__ import annotations + +from fastapi import APIRouter, Header, Query +from fastapi.responses import JSONResponse + +from ai2apps.api.errors import platform_error_response, repository_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.api.models import ( + EventListResponse, + EventResponse, + MessageCreateRequest, + MessageListResponse, + MessageResponse, + SessionCreateRequest, + SessionListResponse, + SessionPatchRequest, + SessionResponse, +) +from ai2apps.core import ( + RepositoryError, + SessionKind, + SessionRetention, + SessionStatus, + SessionVisibility, + format_utc, +) +from ai2apps.platform_runtime import PlatformRuntime +from ai2apps.storage import MessagePartInput +from ai2apps.storage.repositories import MessageRepository, SessionRepository + + +def _runtime_or_error( + runtime_provider: PlatformRuntimeProvider, +) -> PlatformRuntime | JSONResponse: + runtime = runtime_provider() + if runtime is None or runtime.database is None or runtime.events is None: + return platform_error_response( + status_code=503, + code="platform_not_ready", + message="AI2Apps platform persistence is not ready.", + retryable=True, + ) + return runtime + + +def _session_defaults(request: SessionCreateRequest): + conversational_embed = request.kind in { + SessionKind.MINI_CHAT, + SessionKind.IN_APP_CHAT, + } + visibility = request.visibility or ( + SessionVisibility.UNLISTED + if conversational_embed + else SessionVisibility.LISTED + ) + retention = request.retention or ( + SessionRetention.TEMPORARY + if conversational_embed + else SessionRetention.DURABLE + ) + return visibility, retention + + +def create_resource_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter() + + @router.post( + "/app-instances/{app_instance_id}/sessions", + response_model=SessionResponse, + status_code=201, + ) + def create_session( + app_instance_id: str, + request: SessionCreateRequest, + x_trace_id: str | None = Header(default=None), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + visibility, retention = _session_defaults(request) + try: + record = SessionRepository(runtime.database, runtime.events).create( + app_instance_id=app_instance_id, + title=request.title, + is_home=request.is_home, + session_kind=request.kind, + visibility=visibility, + retention=retention, + expires_at=( + None + if request.expires_at is None + else format_utc(request.expires_at) + ), + metadata=request.metadata, + trace_id=x_trace_id, + ) + return SessionResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + @router.get( + "/app-instances/{app_instance_id}/sessions", + response_model=SessionListResponse, + ) + def list_sessions( + app_instance_id: str, + kind: SessionKind | None = None, + visibility: SessionVisibility | None = None, + include_deleted: bool = False, + limit: int = Query(default=100, ge=1, le=1_000), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + records = SessionRepository(runtime.database, runtime.events).list_for_instance( + app_instance_id, + include_deleted=include_deleted, + session_kind=kind, + visibility=visibility, + limit=limit, + ) + return SessionListResponse( + items=[SessionResponse.from_record(record) for record in records] + ) + except RepositoryError as error: + return repository_error_response(error) + + @router.get( + "/app-instances/{app_instance_id}/sessions/{session_id}", + response_model=SessionResponse, + ) + def get_session(app_instance_id: str, session_id: str): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + record = SessionRepository(runtime.database, runtime.events).get( + session_id, + app_instance_id=app_instance_id, + ) + return SessionResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + @router.patch( + "/app-instances/{app_instance_id}/sessions/{session_id}", + response_model=SessionResponse, + ) + def patch_session( + app_instance_id: str, + session_id: str, + request: SessionPatchRequest, + x_trace_id: str | None = Header(default=None), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + record = SessionRepository(runtime.database, runtime.events).update( + session_id, + expected_revision=request.expected_revision, + app_instance_id=app_instance_id, + title=request.title, + status=request.status, + is_home=request.is_home, + visibility=request.visibility, + retention=request.retention, + metadata=request.metadata, + trace_id=x_trace_id, + ) + return SessionResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + @router.delete( + "/app-instances/{app_instance_id}/sessions/{session_id}", + response_model=SessionResponse, + ) + def delete_session( + app_instance_id: str, + session_id: str, + expected_revision: int = Query(ge=1), + x_trace_id: str | None = Header(default=None), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + record = SessionRepository(runtime.database, runtime.events).update( + session_id, + expected_revision=expected_revision, + app_instance_id=app_instance_id, + status=SessionStatus.DELETED, + trace_id=x_trace_id, + ) + return SessionResponse.from_record(record) + except RepositoryError as error: + return repository_error_response(error) + + @router.post( + "/sessions/{session_id}/messages", + response_model=MessageResponse, + status_code=201, + ) + def append_message( + session_id: str, + request: MessageCreateRequest, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + x_trace_id: str | None = Header(default=None), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + if ( + idempotency_key is not None + and request.idempotency_key is not None + and idempotency_key != request.idempotency_key + ): + return platform_error_response( + status_code=400, + code="idempotency_key_mismatch", + message="Header and body idempotency keys must match.", + ) + try: + result = MessageRepository(runtime.database, runtime.events).append( + session_id=session_id, + role=request.role, + status=request.status, + parts=tuple( + MessagePartInput(kind=part.kind, content=part.content) + for part in request.parts + ), + idempotency_key=idempotency_key or request.idempotency_key, + metadata=request.metadata, + trace_id=x_trace_id, + ) + response = MessageResponse.from_record( + result.value, + created=result.created, + ) + if not result.created: + return JSONResponse( + status_code=200, + content=response.model_dump(mode="json"), + ) + return response + except RepositoryError as error: + return repository_error_response(error) + + @router.get( + "/sessions/{session_id}/messages", + response_model=MessageListResponse, + ) + def list_messages( + session_id: str, + after: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=1_000), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + records = MessageRepository(runtime.database, runtime.events).list_for_session( + session_id, + after_sequence=after, + limit=limit, + ) + return MessageListResponse( + items=[MessageResponse.from_record(record) for record in records] + ) + except RepositoryError as error: + return repository_error_response(error) + + @router.get( + "/sessions/{session_id}/events", + response_model=EventListResponse, + ) + def list_session_events( + session_id: str, + after: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=1_000), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + events = runtime.events.list_after( + after, + session_id=session_id, + limit=limit, + ) + return EventListResponse( + items=[EventResponse.from_record(event) for event in events] + ) + + return router diff --git a/ai2apps/api/router.py b/ai2apps/api/router.py new file mode 100644 index 00000000..fd0d0e0d --- /dev/null +++ b/ai2apps/api/router.py @@ -0,0 +1,52 @@ +"""Composition root for AI2Apps platform resource APIs.""" + +from __future__ import annotations + +from fastapi import APIRouter + +from ai2apps.api.agents import create_agent_router +from ai2apps.api.browser import create_browser_router +from ai2apps.api.capabilities import create_capability_router +from ai2apps.api.chat import create_chat_router +from ai2apps.api.cloud import create_cloud_router +from ai2apps.api.documents import create_document_router +from ai2apps.api.event_stream import create_event_stream_router +from ai2apps.api.extensions import create_extension_router +from ai2apps.api.health import ( + PlatformConfigProvider, + PlatformRuntimeProvider, + create_health_router, +) +from ai2apps.api.packages import create_package_router +from ai2apps.api.resources import create_resource_router +from ai2apps.api.remote import create_remote_router +from ai2apps.api.secrets import create_secret_router +from ai2apps.api.services import create_service_router +from ai2apps.api.workspace import create_workspace_router + + +def create_ai2apps_router( + *, + config_provider: PlatformConfigProvider | None = None, + runtime_provider: PlatformRuntimeProvider | None = None, +) -> APIRouter: + """Create the versioned AI2Apps platform router.""" + + router = APIRouter(prefix="/v1/platform", tags=["platform"]) + router.include_router(create_health_router(config_provider, runtime_provider)) + if runtime_provider is not None: + router.include_router(create_cloud_router(runtime_provider)) + router.include_router(create_chat_router(runtime_provider)) + router.include_router(create_resource_router(runtime_provider)) + router.include_router(create_event_stream_router(runtime_provider)) + router.include_router(create_extension_router(runtime_provider)) + router.include_router(create_service_router(runtime_provider)) + router.include_router(create_agent_router(runtime_provider)) + router.include_router(create_capability_router(runtime_provider)) + router.include_router(create_workspace_router(runtime_provider)) + router.include_router(create_package_router(runtime_provider)) + router.include_router(create_document_router(runtime_provider)) + router.include_router(create_browser_router(runtime_provider)) + router.include_router(create_secret_router(runtime_provider)) + router.include_router(create_remote_router(runtime_provider)) + return router diff --git a/ai2apps/api/secrets.py b/ai2apps/api/secrets.py new file mode 100644 index 00000000..9f23f256 --- /dev/null +++ b/ai2apps/api/secrets.py @@ -0,0 +1,132 @@ +"""Secret metadata API. No endpoint ever returns a secret value.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field, SecretStr + +from ai2apps.api.errors import platform_error_response, repository_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.core import RepositoryError +from ai2apps.secrets import SecretRecord + + +class SecretResponse(BaseModel): + id: str + uri: str + name: str + purpose: str + allowed_tools: list[str] + status: str + metadata: dict[str, Any] + created_at: datetime + updated_at: datetime + deleted_at: datetime | None + + @classmethod + def from_record(cls, record: SecretRecord): + return cls( + id=record.id, uri=record.uri, name=record.name, + purpose=record.purpose, allowed_tools=list(record.allowed_tools), + status=record.status, metadata=record.metadata, + created_at=record.created_at, updated_at=record.updated_at, + deleted_at=record.deleted_at, + ) + + +class SecretListResponse(BaseModel): + items: list[SecretResponse] + + +class SecretBackendResponse(BaseModel): + provider: str + portable: bool + + +class SecretCreateRequest(BaseModel): + name: str = Field(min_length=1, max_length=128) + value: SecretStr + purpose: str = Field(default="", max_length=500) + allowed_tools: list[str] = Field(min_length=1) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class SecretReplaceRequest(BaseModel): + value: SecretStr + + +def create_secret_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter() + + def repository_or_error(): + runtime = runtime_provider() + if runtime is None or runtime.secrets is None: + return platform_error_response( + status_code=503, code="secret_runtime_not_ready", + message="AI2Apps Secret Store is not ready.", retryable=True, + ) + return runtime.secrets + + @router.get("/secrets/backend", response_model=SecretBackendResponse) + def get_secret_backend(): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + provider = repository.backend.provider_name + return SecretBackendResponse( + provider=provider, + portable=provider == "encrypted-file", + ) + + @router.get("/secrets", response_model=SecretListResponse) + def list_secrets(): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + return SecretListResponse( + items=[SecretResponse.from_record(item) for item in repository.list()] + ) + + @router.post("/secrets", response_model=SecretResponse, status_code=201) + def create_secret(request: SecretCreateRequest): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + return SecretResponse.from_record(repository.create( + name=request.name, value=request.value.get_secret_value(), + purpose=request.purpose, allowed_tools=tuple(request.allowed_tools), + metadata=request.metadata, + )) + except (ValueError, RuntimeError) as error: + return platform_error_response( + status_code=422, code="secret_create_failed", message=str(error) + ) + + @router.put("/secrets/{secret_id}/value", response_model=SecretResponse) + def replace_secret(secret_id: str, request: SecretReplaceRequest): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + return SecretResponse.from_record( + repository.replace(secret_id, request.value.get_secret_value()) + ) + except RepositoryError as error: + return repository_error_response(error) + + @router.delete("/secrets/{secret_id}", response_model=SecretResponse) + def delete_secret(secret_id: str): + repository = repository_or_error() + if isinstance(repository, JSONResponse): + return repository + try: + return SecretResponse.from_record(repository.delete(secret_id)) + except RepositoryError as error: + return repository_error_response(error) + + return router diff --git a/ai2apps/api/services.py b/ai2apps/api/services.py new file mode 100644 index 00000000..0f2fa111 --- /dev/null +++ b/ai2apps/api/services.py @@ -0,0 +1,419 @@ +"""Service Registry, Tool discovery, lifecycle, and invocation APIs.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, Header +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from ai2apps.api.errors import platform_error_response, repository_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.core import RepositoryError +from ai2apps.packages import PackageError +from ai2apps.platform_runtime import PlatformRuntime +from ai2apps.services import ( + ServiceDescriptorRecord, + ServiceInstanceRecord, + ServiceInstanceStatus, + ServiceRuntimeMode, + ServiceStatus, + ToolCallContext, + ToolDescriptorRecord, + ToolGatewayError, + ToolInvocationRecord, + ToolInvocationStatus, +) + + +class ServiceInstanceResponse(BaseModel): + id: str + provider_key: str + status: ServiceInstanceStatus + endpoint: str | None + health: dict[str, Any] + last_error: str | None + revision: int + + @classmethod + def from_record(cls, record: ServiceInstanceRecord) -> ServiceInstanceResponse: + return cls(**{name: getattr(record, name) for name in cls.model_fields}) + + +class ServiceResponse(BaseModel): + id: str + service_key: str + package_id: str + package_version: str + display_name: str + runtime_mode: ServiceRuntimeMode + source: str + status: ServiceStatus + capabilities: list[str] + dependencies: list[dict[str, Any]] + config: dict[str, Any] + package_digest: str | None + permissions: dict[str, Any] + revision: int + created_at: datetime + updated_at: datetime + instance: ServiceInstanceResponse | None = None + + @classmethod + def from_record( + cls, + record: ServiceDescriptorRecord, + instance: ServiceInstanceRecord | None = None, + ) -> ServiceResponse: + return cls( + id=record.id, + service_key=record.service_key, + package_id=record.package_id, + package_version=record.package_version, + display_name=record.display_name, + runtime_mode=record.runtime_mode, + source=record.source, + status=record.status, + capabilities=list(record.capabilities), + dependencies=[ + { + "service_key": dependency.service_key, + "version_spec": dependency.version_spec, + "optional": dependency.optional, + } + for dependency in record.dependencies + ], + config=record.config, + package_digest=record.package_digest, + permissions=record.permissions, + revision=record.revision, + created_at=record.created_at, + updated_at=record.updated_at, + instance=( + None + if instance is None + else ServiceInstanceResponse.from_record(instance) + ), + ) + + +class ServiceListResponse(BaseModel): + items: list[ServiceResponse] + + +class ServiceLifecycleRequest(BaseModel): + expected_revision: int = Field(ge=1) + + +class ToolResponse(BaseModel): + id: str + service_id: str + qualified_name: str + display_name: str + description: str + input_schema: dict[str, Any] + output_schema: dict[str, Any] + effects: list[str] + required_capabilities: list[str] + capability_rules: list[dict[str, Any]] + retry_policy: dict[str, Any] + timeout_ms: int + + @classmethod + def from_record(cls, record: ToolDescriptorRecord) -> ToolResponse: + return cls( + id=record.id, + service_id=record.service_id, + qualified_name=record.qualified_name, + display_name=record.display_name, + description=record.description, + input_schema=record.input_schema, + output_schema=record.output_schema, + effects=list(record.effects), + required_capabilities=list(record.required_capabilities), + capability_rules=list(record.capability_rules), + retry_policy=record.retry_policy, + timeout_ms=record.timeout_ms, + ) + + +class ToolListResponse(BaseModel): + items: list[ToolResponse] + + +class ToolInvokeRequest(BaseModel): + arguments: dict[str, Any] = Field(default_factory=dict) + session_id: str | None = None + timeout_ms: int | None = Field(default=None, ge=1) + + +class ToolInvokeResponse(BaseModel): + invocation_id: str + tool_id: str + qualified_name: str + provider_key: str + output: dict[str, Any] + duration_ms: int + + +class ToolInvocationResponse(BaseModel): + id: str + tool_id: str + qualified_name: str + provider_key: str + caller_id: str + session_id: str | None + trace_id: str | None + status: ToolInvocationStatus + arguments: dict[str, Any] + output: dict[str, Any] | None + error: dict[str, Any] | None + progress: dict[str, Any] + timeout_ms: int + attempt: int + duration_ms: int | None + revision: int + created_at: datetime + updated_at: datetime + finished_at: datetime | None + + @classmethod + def from_record(cls, record: ToolInvocationRecord) -> ToolInvocationResponse: + return cls(**{name: getattr(record, name) for name in cls.model_fields}) + + +class ToolInvocationListResponse(BaseModel): + items: list[ToolInvocationResponse] + + +def _runtime_or_error( + runtime_provider: PlatformRuntimeProvider, +) -> PlatformRuntime | JSONResponse: + runtime = runtime_provider() + if ( + runtime is None + or runtime.services is None + or runtime.service_registry is None + or runtime.tools is None + ): + return platform_error_response( + status_code=503, + code="platform_not_ready", + message="AI2Apps Service runtime is not ready.", + retryable=True, + ) + return runtime + + +def _gateway_error(error: ToolGatewayError) -> JSONResponse: + status = { + "tool_not_found": 404, + "session_not_found": 404, + "invalid_tool_input": 422, + "invalid_timeout": 422, + "capability_denied": 403, + "tool_disabled": 409, + "service_disabled": 409, + "provider_identity_mismatch": 409, + "invalid_tool_output": 502, + "provider_error": 502, + "provider_unavailable": 503, + "service_unavailable": 503, + "tool_timeout": 504, + }.get(error.code, 500) + return platform_error_response( + status_code=status, + code=error.code, + message=str(error), + retryable=error.retryable, + details=error.details, + ) + + +def create_service_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter() + + @router.get("/services", response_model=ServiceListResponse) + def list_services(): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + items = [] + for service in runtime.services.list_services(): + try: + instance = runtime.services.get_instance_for_service(service.id) + except RepositoryError: + instance = None + items.append(ServiceResponse.from_record(service, instance)) + return ServiceListResponse(items=items) + + @router.get("/services/{service_key}", response_model=ServiceResponse) + def get_service(service_key: str): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + service = runtime.services.get_service(service_key) + instance = runtime.services.get_instance_for_service(service.id) + return ServiceResponse.from_record(service, instance) + except RepositoryError as error: + return repository_error_response(error) + + async def change_enabled( + service_key: str, request: ServiceLifecycleRequest, enabled: bool + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + managed = ( + runtime.package_manager is not None + and runtime.package_repository is not None + and runtime.package_repository.active(service_key) is not None + ) + if managed: + operation = ( + runtime.package_manager.enable + if enabled + else runtime.package_manager.disable + ) + service = await operation(service_key, request.expected_revision) + else: + service = await runtime.service_registry.set_enabled( + service_key, + expected_revision=request.expected_revision, + enabled=enabled, + ) + instance = runtime.services.get_instance_for_service(service.id) + return ServiceResponse.from_record(service, instance) + except RepositoryError as error: + return repository_error_response(error) + except ToolGatewayError as error: + return _gateway_error(error) + except PackageError as error: + return platform_error_response( + status_code=409, + code=error.code, + message=str(error), + details=error.details, + ) + + @router.post("/services/{service_key}/enable", response_model=ServiceResponse) + async def enable_service(service_key: str, request: ServiceLifecycleRequest): + return await change_enabled(service_key, request, True) + + @router.post("/services/{service_key}/disable", response_model=ServiceResponse) + async def disable_service(service_key: str, request: ServiceLifecycleRequest): + return await change_enabled(service_key, request, False) + + @router.post("/services/{service_key}/restart", response_model=ServiceResponse) + async def restart_service(service_key: str): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + if ( + runtime.package_manager is not None + and runtime.package_repository is not None + and runtime.package_repository.active(service_key) is not None + ): + await runtime.package_manager.restart(service_key) + else: + await runtime.service_registry.restart(service_key) + service = runtime.services.get_service(service_key) + instance = runtime.services.get_instance_for_service(service.id) + return ServiceResponse.from_record(service, instance) + except RepositoryError as error: + return repository_error_response(error) + except ToolGatewayError as error: + return _gateway_error(error) + + @router.get("/tools", response_model=ToolListResponse) + def list_tools(): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + context = ToolCallContext(caller_id="api:authenticated") + return ToolListResponse( + items=[ + ToolResponse.from_record(tool) + for tool in runtime.tools.list_tools(context) + ] + ) + + @router.get( + "/tool-invocations", response_model=ToolInvocationListResponse + ) + def list_tool_invocations( + session_id: str | None = None, + trace_id: str | None = None, + status: ToolInvocationStatus | None = None, + limit: int = 100, + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + return ToolInvocationListResponse( + items=[ + ToolInvocationResponse.from_record(item) + for item in runtime.services.list_invocations( + session_id=session_id, + trace_id=trace_id, + status=status, + limit=limit, + ) + ] + ) + + @router.get( + "/tool-invocations/{invocation_id}", + response_model=ToolInvocationResponse, + ) + def get_tool_invocation(invocation_id: str): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + return ToolInvocationResponse.from_record( + runtime.services.get_invocation(invocation_id) + ) + except RepositoryError as error: + return repository_error_response(error) + + @router.post( + "/tools/{qualified_name}/invoke", + response_model=ToolInvokeResponse, + ) + async def invoke_tool( + qualified_name: str, + request: ToolInvokeRequest, + x_trace_id: str | None = Header(default=None), + ): + runtime = _runtime_or_error(runtime_provider) + if isinstance(runtime, JSONResponse): + return runtime + try: + result = await runtime.tools.execute( + qualified_name, + request.arguments, + context=ToolCallContext( + caller_id="api:authenticated", + session_id=request.session_id, + trace_id=x_trace_id, + ), + timeout_ms=request.timeout_ms, + ) + return ToolInvokeResponse( + invocation_id=result.invocation_id, + tool_id=result.tool_id, + qualified_name=result.qualified_name, + provider_key=result.provider_key, + output=result.output, + duration_ms=result.duration_ms, + ) + except ToolGatewayError as error: + return _gateway_error(error) + + return router diff --git a/ai2apps/api/workspace.py b/ai2apps/api/workspace.py new file mode 100644 index 00000000..0fde752f --- /dev/null +++ b/ai2apps/api/workspace.py @@ -0,0 +1,295 @@ +"""Session Workspace, ResourceHandle, and Artifact user APIs.""" + +from __future__ import annotations + +import base64 +import binascii +from typing import Any + +from fastapi import APIRouter, Query +from fastapi.responses import JSONResponse, Response +from pydantic import BaseModel, Field + +from ai2apps.api.errors import platform_error_response, repository_error_response +from ai2apps.api.health import PlatformRuntimeProvider +from ai2apps.core import RepositoryError +from ai2apps.workspace import ArtifactRecord, ResourceHandleRecord, WorkspaceError + + +class ResourceImportRequest(BaseModel): + filename: str = Field(min_length=1, max_length=255) + content_base64: str = Field(min_length=1) + media_type: str | None = None + + +class ResourceHandleResponse(BaseModel): + id: str + uri: str + kind: str + display_name: str + capabilities: list[str] + media_type: str | None + size_bytes: int | None + content_hash: str | None + source: str + + @classmethod + def from_record(cls, value: ResourceHandleRecord): + return cls( + id=value.id, + uri=value.uri, + kind=value.kind.value, + display_name=value.display_name, + capabilities=list(value.capabilities), + media_type=value.media_type, + size_bytes=value.size_bytes, + content_hash=value.content_hash, + source=value.source, + ) + + +class ResourceHandleListResponse(BaseModel): + items: list[ResourceHandleResponse] + + +class WorkspaceWriteRequest(BaseModel): + path: str = Field(min_length=1) + content: str + encoding: str = Field(default="utf-8", pattern="^(utf-8|base64)$") + + +class ArtifactCreateRequest(BaseModel): + path: str = Field(min_length=1) + name: str | None = None + media_type: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ArtifactResponse(BaseModel): + id: str + uri: str + name: str + media_type: str + content_hash: str + size_bytes: int + metadata: dict[str, Any] + + @classmethod + def from_record(cls, value: ArtifactRecord): + return cls( + id=value.id, + uri=value.uri, + name=value.name, + media_type=value.media_type, + content_hash=value.content_hash, + size_bytes=value.size_bytes, + metadata=value.metadata, + ) + + +class ArtifactListResponse(BaseModel): + items: list[ArtifactResponse] + + +def create_workspace_router(runtime_provider: PlatformRuntimeProvider) -> APIRouter: + router = APIRouter() + + def workspace_or_error(): + runtime = runtime_provider() + if runtime is None or runtime.workspace is None: + return platform_error_response( + status_code=503, + code="workspace_runtime_not_ready", + message="AI2Apps Workspace Runtime is not ready.", + retryable=True, + ) + return runtime.workspace + + def workspace_error(error: WorkspaceError): + status = ( + 413 + if error.code in {"resource_too_large", "workspace_quota_exceeded"} + else 422 + ) + return platform_error_response( + status_code=status, code=error.code, message=str(error) + ) + + @router.get("/sessions/{session_id}/workspace") + def list_workspace( + session_id: str, + path: str = ".", + offset: int = Query(0, ge=0), + limit: int = Query(200, ge=1, le=1000), + ): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + try: + return workspace.list(session_id, path, offset=offset, limit=limit) + except RepositoryError as error: + return repository_error_response(error) + except WorkspaceError as error: + return workspace_error(error) + + @router.get("/sessions/{session_id}/workspace/read") + def read_workspace( + session_id: str, + path: str, + offset: int = Query(0, ge=0), + limit: int = Query(1024 * 1024, ge=1, le=1024 * 1024), + ): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + try: + return workspace.read(session_id, path, offset=offset, limit=limit) + except RepositoryError as error: + return repository_error_response(error) + except WorkspaceError as error: + return workspace_error(error) + + @router.put("/sessions/{session_id}/workspace") + def write_workspace(session_id: str, request: WorkspaceWriteRequest): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + try: + return workspace.write( + session_id, request.path, request.content, encoding=request.encoding + ) + except RepositoryError as error: + return repository_error_response(error) + except (WorkspaceError, binascii.Error, ValueError) as error: + if isinstance(error, WorkspaceError): + return workspace_error(error) + return platform_error_response( + status_code=422, code="invalid_content_encoding", message=str(error) + ) + + @router.post( + "/sessions/{session_id}/resource-handles/import", + response_model=ResourceHandleResponse, + status_code=201, + ) + def import_resource(session_id: str, request: ResourceImportRequest): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + try: + data = base64.b64decode(request.content_base64, validate=True) + return ResourceHandleResponse.from_record( + workspace.import_bytes( + session_id, request.filename, data, media_type=request.media_type + ) + ) + except RepositoryError as error: + return repository_error_response(error) + except binascii.Error: + return platform_error_response( + status_code=422, + code="invalid_base64", + message="content_base64 is invalid.", + ) + except WorkspaceError as error: + return workspace_error(error) + + @router.get( + "/sessions/{session_id}/resource-handles", + response_model=ResourceHandleListResponse, + ) + def list_handles(session_id: str): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + return ResourceHandleListResponse( + items=[ + ResourceHandleResponse.from_record(item) + for item in workspace.list_handles(session_id) + ] + ) + + @router.delete( + "/sessions/{session_id}/resource-handles/{handle_id}", status_code=204 + ) + def revoke_handle(session_id: str, handle_id: str): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + try: + workspace.revoke_handle(session_id, handle_id) + return Response(status_code=204) + except RepositoryError as error: + return repository_error_response(error) + + @router.post( + "/sessions/{session_id}/artifacts", + response_model=ArtifactResponse, + status_code=201, + ) + def create_artifact(session_id: str, request: ArtifactCreateRequest): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + try: + return ArtifactResponse.from_record( + workspace.create_artifact( + session_id, + request.path, + request.name, + media_type=request.media_type, + metadata=request.metadata, + ) + ) + except RepositoryError as error: + return repository_error_response(error) + except WorkspaceError as error: + return workspace_error(error) + + @router.get("/sessions/{session_id}/artifacts", response_model=ArtifactListResponse) + def list_artifacts(session_id: str): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + return ArtifactListResponse( + items=[ + ArtifactResponse.from_record(item) + for item in workspace.list_artifacts(session_id) + ] + ) + + @router.get("/sessions/{session_id}/artifacts/{artifact_id}/preview") + def preview_artifact( + session_id: str, + artifact_id: str, + limit: int = Query(256 * 1024, ge=1, le=1024 * 1024), + ): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + try: + return workspace.preview_artifact(session_id, artifact_id, limit) + except RepositoryError as error: + return repository_error_response(error) + + @router.get("/sessions/{session_id}/artifacts/{artifact_id}/download") + def download_artifact(session_id: str, artifact_id: str): + workspace = workspace_or_error() + if isinstance(workspace, JSONResponse): + return workspace + try: + artifact = workspace.get_artifact(session_id, artifact_id) + data = workspace.artifact_path(artifact).read_bytes() + safe = artifact.name.replace('"', "") + return Response( + data, + media_type=artifact.media_type, + headers={ + "Content-Disposition": f'attachment; filename="{safe}"', + "ETag": artifact.content_hash, + }, + ) + except RepositoryError as error: + return repository_error_response(error) + + return router diff --git a/ai2apps/apps/__init__.py b/ai2apps/apps/__init__.py new file mode 100644 index 00000000..4a9851bd --- /dev/null +++ b/ai2apps/apps/__init__.py @@ -0,0 +1,5 @@ +"""Built-in App definitions shared by the runtime and WebUI Shell.""" + +from .system import SYSTEM_APP_MANIFESTS, ensure_system_apps + +__all__ = ["SYSTEM_APP_MANIFESTS", "ensure_system_apps"] diff --git a/ai2apps/apps/system.py b/ai2apps/apps/system.py new file mode 100644 index 00000000..c34c844e --- /dev/null +++ b/ai2apps/apps/system.py @@ -0,0 +1,304 @@ +"""Idempotent built-in system App registration.""" + +from __future__ import annotations + +import json +import sqlite3 +from typing import Any + +from ai2apps.core import ( + EntityIdKind, + ResourceConflictError, + new_entity_id, + utc_now_text, +) +from ai2apps.events import EventStore +from ai2apps.storage import PlatformDatabase +from ai2apps.storage.records import canonical_json + +SYSTEM_APP_MANIFESTS: tuple[dict[str, Any], ...] = ( + { + "schema": "ai2apps.app/v1", + "id": "ai2apps.dashboard", + "name": "Dashboard", + "description": "System status and runtime overview", + "version": "1.0.0", + "instances": {"mode": "singleton", "scope": "system"}, + "mobile": {"ready": True}, + "entry": {"kind": "host", "resource": "ai2apps:system/dashboard"}, + "navigation": { + "category": "System", + "icon": "layout-dashboard", + "order": 10, + "pinned_default": True, + }, + "state": {"version": 1, "defaults": {}}, + }, + { + "schema": "ai2apps.app/v1", + "id": "ai2apps.account", + "name": "Account", + "description": "Connect an optional AI2Apps account and manage Cloud points", + "version": "1.0.0", + "instances": {"mode": "singleton", "scope": "user"}, + "mobile": {"ready": True}, + "entry": {"kind": "host", "resource": "ai2apps:system/account"}, + "navigation": { + "category": "System", + "icon": "circle-user-round", + "order": 15, + "pinned_default": False, + }, + "state": {"version": 1, "defaults": {}}, + }, + { + "schema": "ai2apps.app/v1", + "id": "ai2apps.models", + "name": "Models", + "description": "Install, configure, and manage models", + "version": "1.0.0", + "instances": {"mode": "singleton", "scope": "system"}, + "entry": {"kind": "host", "resource": "ai2apps:system/models"}, + "navigation": { + "category": "AI & Models", + "icon": "box", + "order": 20, + "pinned_default": True, + }, + "state": {"version": 1, "defaults": {}}, + }, + { + "schema": "ai2apps.app/v1", + "id": "ai2apps.discover", + "name": "Discover", + "description": "Discover, verify, install, and manage AI2Apps packages", + "version": "1.0.0", + "instances": {"mode": "singleton", "scope": "system"}, + "entry": {"kind": "host", "resource": "ai2apps:system/discover"}, + "navigation": { + "category": "System", + "icon": "compass", + "order": 22, + "pinned_default": True, + }, + "state": {"version": 1, "defaults": {}}, + }, + { + "schema": "ai2apps.app/v1", + "id": "ai2apps.agents", + "name": "Agents", + "description": "Manage Agents, Runs, packages, and local Patches", + "version": "1.0.0", + "instances": {"mode": "singleton", "scope": "system"}, + "mobile": {"ready": True}, + "entry": {"kind": "host", "resource": "ai2apps:system/agents"}, + "navigation": { + "category": "AI & Chat", + "icon": "bot", + "order": 25, + "pinned_default": False, + }, + "state": {"version": 1, "defaults": {}}, + }, + { + "schema": "ai2apps.app/v1", + "id": "ai2apps.general-chat", + "name": "Chat", + "description": "Chat with local models and Agents", + "version": "1.0.0", + "instances": {"mode": "singleton", "scope": "user"}, + "mobile": {"ready": True}, + "mobile_entry": {"kind": "host", "resource": "ai2apps:mobile/chat"}, + "entry": {"kind": "host", "resource": "ai2apps:system/chat"}, + "navigation": { + "category": "AI & Chat", + "icon": "message-square", + "order": 30, + "pinned_default": True, + }, + "session_kind": "chat_thread", + "state": {"version": 1, "defaults": {}}, + }, + { + "schema": "ai2apps.app/v1", + "id": "ai2apps.trust-center", + "name": "Trust Center", + "description": "Review approvals, permissions, secrets, and Safe Mode", + "version": "1.0.0", + "instances": {"mode": "singleton", "scope": "system"}, + "mobile": {"ready": True}, + "entry": {"kind": "host", "resource": "ai2apps:system/trust-center"}, + "navigation": { + "category": "System", + "icon": "shield-check", + "order": 35, + "pinned_default": True, + }, + "state": {"version": 1, "defaults": {}}, + }, + { + "schema": "ai2apps.app/v1", + "id": "ai2apps.settings", + "name": "Settings", + "description": "Configure the AI2Apps system", + "version": "1.0.0", + "instances": {"mode": "singleton", "scope": "system"}, + "entry": {"kind": "host", "resource": "ai2apps:system/settings"}, + "navigation": { + "category": "System", + "icon": "settings", + "order": 40, + "pinned_default": False, + }, + "state": {"version": 1, "defaults": {}}, + }, + { + "schema": "ai2apps.app/v1", + "id": "ai2apps.logs", + "name": "Logs", + "description": "Inspect system and service logs", + "version": "1.0.0", + "instances": {"mode": "singleton", "scope": "system"}, + "entry": {"kind": "host", "resource": "ai2apps:system/logs"}, + "navigation": { + "category": "Developer Tools", + "icon": "scroll-text", + "order": 50, + "pinned_default": False, + }, + "state": {"version": 1, "defaults": {}}, + }, + { + "schema": "ai2apps.app/v1", + "id": "ai2apps.terminal", + "name": "Terminal", + "description": "Interactive system terminal sessions", + "version": "1.0.0", + "instances": {"mode": "singleton", "scope": "system"}, + "entry": {"kind": "host", "resource": "ai2apps:system/terminal"}, + "navigation": { + "category": "Developer Tools", + "icon": "square-terminal", + "order": 55, + "pinned_default": False, + }, + "state": {"version": 1, "defaults": {}}, + }, + { + "schema": "ai2apps.app/v1", + "id": "ai2apps.coder", + "name": "Coder", + "description": "Build software with terminal-based coding Agents", + "version": "1.0.0", + "instances": {"mode": "singleton", "scope": "user"}, + "entry": {"kind": "host", "resource": "ai2apps:system/coder"}, + "navigation": { + "category": "Developer Tools", + "icon": "code-2", + "order": 58, + "pinned_default": True, + }, + "presentation": {"dock_reveal": False}, + "state": {"version": 1, "defaults": {}}, + }, + { + "schema": "ai2apps.app/v1", + "id": "ai2apps.benchmark", + "name": "Bench", + "description": "Measure model and device performance", + "version": "1.0.0", + "instances": {"mode": "singleton", "scope": "system"}, + "entry": {"kind": "host", "resource": "ai2apps:system/benchmark"}, + "navigation": { + "category": "Developer Tools", + "icon": "gauge", + "order": 60, + "pinned_default": False, + }, + "state": {"version": 1, "defaults": {}}, + }, +) + + +def ensure_system_apps( + database: PlatformDatabase, + events: EventStore, + *, + trace_id: str | None = None, +) -> None: + """Register or refresh immutable product-owned App manifests.""" + + now = utc_now_text() + try: + with database.transaction(write=True) as connection: + for manifest in SYSTEM_APP_MANIFESTS: + package_id = str(manifest["id"]) + row = connection.execute( + "SELECT * FROM app_definitions WHERE package_id=? " + "ORDER BY created_at DESC LIMIT 1", + (package_id,), + ).fetchone() + instances = manifest["instances"] + mode = str(instances["mode"]) + scope = str(instances["scope"]) + if row is not None: + if ( + row["source"] != "builtin" + or row["instance_mode"] != mode + or row["singleton_scope"] != scope + ): + raise ResourceConflictError( + "Reserved system App has incompatible definition: " + f"{package_id}" + ) + if ( + json.loads(row["manifest_json"]) != manifest + or row["display_name"] != manifest["name"] + or row["status"] != "enabled" + ): + connection.execute( + "UPDATE app_definitions SET display_name=?," + "status='enabled',manifest_json=?,revision=revision+1," + "updated_at=? WHERE id=?", + ( + manifest["name"], + canonical_json(manifest), + now, + row["id"], + ), + ) + continue + definition_id = new_entity_id(EntityIdKind.APP_DEFINITION) + connection.execute( + """ + INSERT INTO app_definitions( + id,package_id,package_version,display_name,instance_mode, + singleton_scope,source,status,manifest_schema_version, + manifest_json,created_at,updated_at + ) VALUES(?,?,?,?,?,?,'builtin','enabled',1,?,?,?) + """, + ( + definition_id, + package_id, + manifest["version"], + manifest["name"], + mode, + scope, + canonical_json(manifest), + now, + now, + ), + ) + events.append_in_transaction( + connection, + event_type="app.definition.created", + subject_id=definition_id, + trace_id=trace_id, + payload={ + "package_id": package_id, + "package_version": manifest["version"], + "source": "builtin", + }, + ) + except sqlite3.IntegrityError as exc: + raise ResourceConflictError(str(exc)) from exc diff --git a/ai2apps/browser/__init__.py b/ai2apps/browser/__init__.py new file mode 100644 index 00000000..69b5b5d3 --- /dev/null +++ b/ai2apps/browser/__init__.py @@ -0,0 +1,25 @@ +"""Managed browser runtime exports.""" + +from .chrome import ChromeBrowserBackend +from .manager import BrowserManager +from .models import ( + AuthenticationChallenge, + BrowserArticle, + BrowserControlState, + BrowserError, + BrowserRuntimeConfig, + BrowserSnapshot, +) +from .service import install_browser_service + +__all__ = [ + "AuthenticationChallenge", + "BrowserArticle", + "BrowserControlState", + "BrowserError", + "BrowserManager", + "BrowserRuntimeConfig", + "BrowserSnapshot", + "ChromeBrowserBackend", + "install_browser_service", +] diff --git a/ai2apps/browser/article.py b/ai2apps/browser/article.py new file mode 100644 index 00000000..8e5764cf --- /dev/null +++ b/ai2apps/browser/article.py @@ -0,0 +1,80 @@ +"""Canonical article formatting for the managed browser.""" + +from __future__ import annotations + +import re +from html import escape, unescape + +_BLANK_LINES = re.compile(r"\n{3,}") +_TAG = re.compile(r"<[^>]+>") + + +def article_html_to_markdown( + html: str, + *, + title: str | None = None, + byline: str | None = None, + published_at: str | None = None, +) -> str: + """Convert already-sanitized reader HTML into stable, compact Markdown.""" + + try: + from markdownify import markdownify + except ImportError as exc: # pragma: no cover - packaging guard + raise RuntimeError( + "browser.read_article requires the markdownify package" + ) from exc + + def code_language(element) -> str | None: + code = element.find("code") + return None if code is None else code.attrs.get("data-ai2apps-code-lang") + + body = markdownify( + html, + heading_style="ATX", + bullets="-", + strip=["script", "style", "form", "button", "input", "textarea"], + code_language_callback=code_language, + ).strip() + header: list[str] = [] + if title: + header.append(f"# {title.strip()}") + details = " · ".join(value.strip() for value in (byline, published_at) if value) + if details: + header.append(f"*{details}*") + result = "\n\n".join([*header, body] if body else header) + return _BLANK_LINES.sub("\n\n", result).strip() + + +def canonical_article_html( + body_html: str, + *, + title: str | None = None, + byline: str | None = None, + published_at: str | None = None, + language: str | None = None, + direction: str | None = None, +) -> str: + """Wrap sanitized reader content in a standalone semantic article.""" + + attrs = ['data-ai2apps-reader="true"'] + if language: + attrs.append(f'lang="{escape(language, quote=True)}"') + if direction in {"ltr", "rtl", "auto"}: + attrs.append(f'dir="{direction}"') + header: list[str] = [] + if title: + header.append(f"

{escape(title)}

") + if byline: + header.append(f'

{escape(byline)}

') + if published_at: + value = escape(published_at, quote=True) + header.append(f'') + header_html = f"
{''.join(header)}
" if header else "" + return f"
{header_html}{body_html}
" + + +def plain_text_from_html(html: str) -> str: + """Small dependency-free fallback used for HTML-only responses and tests.""" + + return re.sub(r"\s+", " ", unescape(_TAG.sub(" ", html))).strip() diff --git a/ai2apps/browser/chrome.py b/ai2apps/browser/chrome.py new file mode 100644 index 00000000..82988648 --- /dev/null +++ b/ai2apps/browser/chrome.py @@ -0,0 +1,1628 @@ +"""Visible Chrome runtime with a WebDriver BiDi event connection.""" + +from __future__ import annotations + +import json +import math +import platform +import random +import re +import threading +import time +from collections import deque +from contextlib import contextmanager, suppress +from pathlib import Path +from typing import Any + +from .models import ( + AuthenticationChallenge, + BrowserArticle, + BrowserError, + BrowserRuntimeConfig, + BrowserSnapshot, +) + +_READABILITY_SOURCE = Path(__file__).with_name("readability.js").read_text( + encoding="utf-8" +) + +_SNAPSHOT_SCRIPT = r""" +const options = arguments[0]; +const maxItems = options.maxItems; +const maxText = options.maxText; +const maxHtml = options.maxHtml; +const htmlMode = options.htmlMode; +const selector = [ + 'a[href]', 'button', 'input:not([type="hidden"])', 'textarea', 'select', + '[role="button"]', '[role="link"]', '[contenteditable="true"]' +].join(','); +const allRoots = (root = document) => { + const roots = [root]; + for (const el of root.querySelectorAll('*')) { + if (el.shadowRoot && el.shadowRoot.mode === 'open') { + roots.push(...allRoots(el.shadowRoot)); + } + } + return roots; +}; +const queryAll = selector => allRoots().flatMap(root => [...root.querySelectorAll(selector)]); +const visible = (el) => { + if (!el || el.closest('[hidden],[aria-hidden="true"],[inert]')) return false; + if (typeof el.checkVisibility === 'function' && !el.checkVisibility({ + checkOpacity: true, + checkVisibilityCSS: true, + })) return false; + const style = getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.visibility !== 'hidden' && style.display !== 'none' && + style.opacity !== '0' && style.contentVisibility !== 'hidden' && + rect.width > 0 && rect.height > 0; +}; +const items = []; +if (!Number.isSafeInteger(window.__ai2appsNextElementRef)) { + window.__ai2appsNextElementRef = 1; +} +if (!(window.__ai2appsElementRefs instanceof WeakMap)) { + window.__ai2appsElementRefs = new WeakMap(); +} +if (!window.__ai2appsRefFingerprints || typeof window.__ai2appsRefFingerprints !== 'object') { + window.__ai2appsRefFingerprints = Object.create(null); +} +const elementRef = (el) => { + let ref = window.__ai2appsElementRefs.get(el); + if (!ref) { + ref = `e${window.__ai2appsNextElementRef++}`; + window.__ai2appsElementRefs.set(el, ref); + } + if (el.getAttribute('data-ai2apps-ref') !== ref) { + el.setAttribute('data-ai2apps-ref', ref); + } + return ref; +}; +const roundedRect = (el) => { + const rect = el.getBoundingClientRect(); + return [rect.x, rect.y, rect.width, rect.height].map( + value => Math.round(value * 10) / 10 + ); +}; +for (const el of queryAll(selector)) { + if (!visible(el) || items.length >= maxItems) continue; + const ref = elementRef(el); + const password = el.matches('input[type="password"]') || + ['current-password', 'new-password', 'one-time-code'].includes(el.autocomplete); + items.push({ + ref, + tag: el.tagName.toLowerCase(), + role: el.getAttribute('role'), + type: el.getAttribute('type'), + text: password ? '[sensitive field]' : + String(el.innerText || el.getAttribute('aria-label') || + el.getAttribute('placeholder') || el.value || '').trim().slice(0, 300), + href: el.tagName === 'A' ? el.href : null, + disabled: Boolean(el.disabled || el.getAttribute('aria-disabled') === 'true'), + sensitive: password, + rect: roundedRect(el), + }); + window.__ai2appsRefFingerprints[ref] = { + tag: el.tagName.toLowerCase(), + role: el.getAttribute('role') || '', + type: el.getAttribute('type') || '', + text: password ? '' : String( + el.innerText || el.getAttribute('aria-label') || + el.getAttribute('placeholder') || el.value || '' + ).replace(/\s+/g, ' ').trim().slice(0, 300), + ariaLabel: el.getAttribute('aria-label') || '', + placeholder: el.getAttribute('placeholder') || '', + href: el.tagName === 'A' ? el.href : '', + rect: roundedRect(el), + }; +} +const textParts = []; +let textLength = 0; +if (document.body) { + const duplicateInteractive = [ + 'a[href]', 'button', 'input', 'textarea', 'select', + '[role="button"]', '[role="link"]', '[contenteditable="true"]' + ].join(','); + for (const root of allRoots(document.body)) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + while (walker.nextNode() && textLength < maxText) { + const node = walker.currentNode; + const parent = node.parentElement; + if (!parent || !visible(parent) || parent.closest(duplicateInteractive)) continue; + if (parent.closest('script,style,noscript,template')) continue; + const value = String(node.nodeValue || '').replace(/\s+/g, ' ').trim(); + if (value) { + textParts.push(value); + textLength += value.length + 1; + } + } + } +} +const escapeText = (value) => String(value) + .replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +const escapeAttr = (value) => escapeText(value).replaceAll('"', '"'); +const excludedTags = new Set([ + 'SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'HEAD', 'META', 'LINK', 'BASE' +]); +const voidTags = new Set([ + 'AREA', 'BASE', 'BR', 'COL', 'EMBED', 'HR', 'IMG', 'INPUT', 'LINK', + 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR' +]); +const keptAttributes = new Set([ + 'id', 'name', 'type', 'role', 'href', 'src', 'alt', 'title', 'placeholder', + 'for', 'action', 'method', 'target', 'rel', 'contenteditable', 'tabindex' +]); +const serializeAttributes = (el) => { + const attrs = []; + for (const attr of el.attributes) { + const name = attr.name.toLowerCase(); + if (name.startsWith('on') || name === 'style' || name === 'class' || + name === 'value' || name === 'data-ai2apps-ref') continue; + if (!keptAttributes.has(name) && !name.startsWith('aria-')) continue; + attrs.push(`${name}="${escapeAttr(attr.value)}"`); + } + if (el.disabled) attrs.push('disabled=""'); + if (el.checked) attrs.push('checked=""'); + if (el.selected) attrs.push('selected=""'); + if (el.matches(selector)) attrs.push(`data-ai2apps-ref="${elementRef(el)}"`); + attrs.push(`data-ai2apps-rect="${roundedRect(el).join(',')}"`); + return attrs.length ? ' ' + attrs.join(' ') : ''; +}; +const textIsRendered = (node) => { + const parent = node.parentElement; + if (!parent || !visible(parent)) return false; + const range = document.createRange(); + range.selectNodeContents(node); + return [...range.getClientRects()].some(rect => rect.width > 0 && rect.height > 0); +}; +const snapElement = (el) => { + if (excludedTags.has(el.tagName)) return ''; + const style = getComputedStyle(el); + const hardHidden = el.closest('[hidden],[aria-hidden="true"],[inert]') || + style.display === 'none' || style.opacity === '0' || + style.contentVisibility === 'hidden'; + if (hardHidden) return ''; + const children = []; + for (const child of el.childNodes) { + if (child.nodeType === Node.TEXT_NODE) { + if (textIsRendered(child)) { + const value = String(child.nodeValue || '').replace(/\s+/g, ' ').trim(); + if (value) children.push(escapeText(value)); + } + } else if (child.nodeType === Node.ELEMENT_NODE) { + const value = snapElement(child); + if (value) children.push(value); + } + } + if (el.shadowRoot && el.shadowRoot.mode === 'open') { + const shadowChildren = []; + for (const child of el.shadowRoot.childNodes) { + if (child.nodeType === Node.TEXT_NODE) { + const value = String(child.nodeValue || '').replace(/\s+/g, ' ').trim(); + if (value) shadowChildren.push(escapeText(value)); + } else if (child.nodeType === Node.ELEMENT_NODE) { + const value = snapElement(child); + if (value) shadowChildren.push(value); + } + } + if (shadowChildren.length) { + children.push(``); + } + } + // A zero-sized or visibility-hidden wrapper may contain positioned children + // which are rendered. Promote those children instead of deleting the subtree. + if (!visible(el)) return children.join(''); + const tag = el.tagName.toLowerCase(); + const attrs = serializeAttributes(el); + if (voidTags.has(el.tagName)) return `<${tag}${attrs}>`; + return `<${tag}${attrs}>${children.join('')}`; +}; +let html; +let htmlTruncated = false; +if (htmlMode === 'full') { + const clone = document.documentElement.cloneNode(true); + for (const field of clone.querySelectorAll( + 'input[type="password"],[autocomplete="current-password"],'+ + '[autocomplete="new-password"],[autocomplete="one-time-code"]')) { + field.removeAttribute('value'); + } + html = '\n' + clone.outerHTML; + if (html.length > 2000000) { + throw new Error('full_html_too_large: document exceeds 2,000,000 characters'); + } +} else { + html = document.body ? snapElement(document.body) : ''; + if (html.length > maxHtml) { + html = html.slice(0, maxHtml) + ''; + htmlTruncated = true; + } +} +return { + url: location.href, + title: document.title, + items, + text: textParts.join(' ').replace(/\s+/g, ' ').trim().slice(0, maxText), + html, + htmlMode, + htmlTruncated, +}; +""" + +_AUTH_SCRIPT = r""" +const roots = [document]; +for (let index = 0; index < roots.length; index++) { + for (const host of roots[index].querySelectorAll('*')) { + if (host.shadowRoot) roots.push(host.shadowRoot); + } +} +const visible = (el) => { + if (!el) return false; + const style = getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.visibility !== 'hidden' && style.display !== 'none' && + rect.width > 0 && rect.height > 0; +}; +const firstVisible = (selector) => roots.flatMap( + root => [...root.querySelectorAll(selector)] +).find(visible); +const password = firstVisible('input[type="password"],'+ + 'input[autocomplete="current-password"],input[autocomplete="new-password"]'); +if (password) return {kind: 'login', reason: 'A password field is present'}; +const otp = firstVisible('input[autocomplete="one-time-code"],'+ + 'input[name*="otp" i],input[id*="otp" i],input[name*="verification" i]'); +if (otp) return {kind: 'two_factor', reason: 'A verification-code field is present'}; +const captcha = firstVisible( + 'iframe[src*="captcha" i],iframe[title*="captcha" i],'+ + '[id*="captcha" i],[class*="captcha" i],'+ + '[id*="challenge" i][role], [class*="challenge" i][role]'); +if (captcha) return {kind: 'captcha', reason: 'A CAPTCHA or browser challenge is present'}; +return null; +""" + +_ARTICLE_SCRIPT = r""" +const options = arguments[0]; +const liveDocument = document; +const clone = document.cloneNode(true); +const warnings = []; +let hiddenNodesRemoved = 0; + +const hardHidden = (el) => { + if (!el || !el.isConnected) return false; + if (el.closest('[hidden],[aria-hidden="true"],[inert]')) return true; + const style = getComputedStyle(el); + return style.display === 'none' || style.visibility === 'hidden' || + style.visibility === 'collapse' || style.opacity === '0' || + style.contentVisibility === 'hidden'; +}; + +// The live and cloned trees have identical child ordering at this point. Use +// computed style from the rendered page while deleting only from the clone. +const pruneHiddenPair = (liveNode, cloneNode) => { + const liveChildren = [...liveNode.childNodes]; + const cloneChildren = [...cloneNode.childNodes]; + for (let index = liveChildren.length - 1; index >= 0; index--) { + const liveChild = liveChildren[index]; + const cloneChild = cloneChildren[index]; + if (!cloneChild) continue; + if (liveChild.nodeType === Node.ELEMENT_NODE && hardHidden(liveChild)) { + cloneChild.remove(); + hiddenNodesRemoved += 1; + continue; + } + if (liveChild.nodeType === Node.ELEMENT_NODE) { + pruneHiddenPair(liveChild, cloneChild); + } + } +}; +if (liveDocument.body && clone.body) pruneHiddenPair(liveDocument.body, clone.body); + +const canonical = liveDocument.querySelector('link[rel~="canonical"][href]'); +const canonicalUrl = canonical ? canonical.href : null; +const sourceForFallback = clone.cloneNode(true); +let parsed = null; +let extractionMethod = 'readability'; + +if (options.selector) { + let selected; + try { + selected = clone.querySelector(options.selector); + } catch (error) { + throw new Error(`invalid_article_selector: ${error.message}`); + } + if (!selected) throw new Error('article_selector_not_found'); + parsed = { + title: liveDocument.title || null, + byline: null, + dir: selected.getAttribute('dir') || liveDocument.dir || null, + lang: selected.getAttribute('lang') || liveDocument.documentElement.lang || null, + content: selected.innerHTML, + textContent: selected.textContent || '', + excerpt: null, + siteName: null, + publishedTime: null, + }; + extractionMethod = 'selector'; +} else { + const Reader = globalThis.__ai2appsReadability; + if (typeof Reader !== 'function') throw new Error('readability_not_loaded'); + for (const code of clone.querySelectorAll('pre code')) { + const languageClass = [...code.classList].find( + value => value.startsWith('language-') || value.startsWith('lang-') + ); + if (languageClass) { + code.setAttribute( + 'data-ai2apps-code-lang', languageClass.replace(/^(language-|lang-)/, '') + ); + } + } + try { + parsed = new Reader(clone, { + charThreshold: options.charThreshold, + maxElemsToParse: options.maxElements, + keepClasses: false, + }).parse(); + } catch (error) { + if (options.mode === 'strict') throw error; + warnings.push(`Readability failed (${error.message}); visible-content fallback was used.`); + } +} + +if (!parsed) { + if (options.mode === 'strict') throw new Error('article_not_found'); + const fallback = sourceForFallback.querySelector( + 'article,main,[role="main"]' + ) || sourceForFallback.body; + if (!fallback) throw new Error('article_not_found'); + parsed = { + title: liveDocument.title || null, + byline: null, + dir: fallback.getAttribute('dir') || liveDocument.dir || null, + lang: fallback.getAttribute('lang') || liveDocument.documentElement.lang || null, + content: fallback.innerHTML, + textContent: fallback.textContent || '', + excerpt: null, + siteName: null, + publishedTime: null, + }; + extractionMethod = fallback === sourceForFallback.body ? 'visible-body' : 'semantic-main'; + warnings.push('Readability did not find an article; semantic visible-content fallback was used.'); +} + +const holder = liveDocument.createElement('div'); +holder.innerHTML = parsed.content || ''; +const forbidden = new Set([ + 'SCRIPT','STYLE','NOSCRIPT','TEMPLATE','FORM','INPUT','BUTTON','TEXTAREA', + 'SELECT','OPTION','IFRAME','FRAME','OBJECT','EMBED','APPLET','PORTAL','DIALOG' +]); +const allowed = new Set([ + 'A','ABBR','ADDRESS','ARTICLE','ASIDE','AUDIO','B','BDI','BDO','BLOCKQUOTE', + 'BR','CAPTION','CITE','CODE','COL','COLGROUP','DD','DEL','DETAILS','DFN','DIV', + 'DL','DT','EM','FIGCAPTION','FIGURE','H1','H2','H3','H4','H5','H6','HEADER', + 'HGROUP','HR','I','IMG','INS','KBD','LI','MAIN','MARK','MATH','OL','P','PICTURE', + 'PRE','Q','S','SAMP','SECTION','SMALL','SOURCE','SPAN','STRONG','SUB','SUMMARY', + 'SUP','TABLE','TBODY','TD','TFOOT','TH','THEAD','TIME','TR','U','UL','VAR','VIDEO' +]); +const globalAttrs = new Set(['title','lang','dir']); +const attrsByTag = { + A: new Set(['href','rel']), IMG: new Set(['src','srcset','alt','width','height']), + SOURCE: new Set(['src','srcset','type','media']), VIDEO: new Set(['src','poster','controls']), + AUDIO: new Set(['src','controls']), TIME: new Set(['datetime']), + OL: new Set(['start','reversed']), LI: new Set(['value']), + TD: new Set(['colspan','rowspan','headers']), TH: new Set(['colspan','rowspan','scope','headers']), + CODE: new Set(['data-ai2apps-code-lang']) +}; +const safeUrl = (value, image) => { + const normalized = String(value || '').trim().toLowerCase(); + if (!normalized) return false; + if (normalized.startsWith('javascript:') || normalized.startsWith('vbscript:')) return false; + if (normalized.startsWith('data:')) return image && normalized.startsWith('data:image/'); + return true; +}; +const unwrap = (node) => { + const parent = node.parentNode; + if (!parent) return; + while (node.firstChild) parent.insertBefore(node.firstChild, node); + node.remove(); +}; + +for (const node of [...holder.querySelectorAll('*')].reverse()) { + if (forbidden.has(node.tagName)) { + node.remove(); + continue; + } + if (!allowed.has(node.tagName)) { + unwrap(node); + continue; + } + if (!options.includeImages && ['IMG','PICTURE','SOURCE'].includes(node.tagName)) { + node.remove(); + continue; + } + if (!options.includeLinks && node.tagName === 'A') { + unwrap(node); + continue; + } + for (const attr of [...node.attributes]) { + const name = attr.name.toLowerCase(); + const tagAttrs = attrsByTag[node.tagName] || new Set(); + if (!globalAttrs.has(name) && !tagAttrs.has(name)) { + node.removeAttribute(attr.name); + continue; + } + if (['href','src','poster'].includes(name) && + !safeUrl(attr.value, node.tagName === 'IMG')) { + node.removeAttribute(attr.name); + } + } + if (node.tagName === 'A' && node.hasAttribute('href')) { + node.setAttribute('rel', 'noopener noreferrer'); + } +} + +let truncated = false; +const fullText = String(holder.textContent || '').replace(/\s+/g, ' ').trim(); +if (fullText.length > options.maxChars) { + truncated = true; + warnings.push(`Article was truncated to ${options.maxChars} text characters.`); + let remaining = options.maxChars; + const walker = liveDocument.createTreeWalker(holder, NodeFilter.SHOW_TEXT); + const textNodes = []; + while (walker.nextNode()) textNodes.push(walker.currentNode); + for (const textNode of textNodes) { + if (remaining <= 0) { + textNode.remove(); + continue; + } + const value = textNode.nodeValue || ''; + if (value.length <= remaining) { + remaining -= value.length; + continue; + } + let cut = value.slice(0, remaining); + const boundary = cut.lastIndexOf(' '); + if (boundary > remaining * 0.75) cut = cut.slice(0, boundary); + textNode.nodeValue = cut + '…'; + remaining = 0; + } + for (const empty of [...holder.querySelectorAll('*')].reverse()) { + if (!empty.textContent.trim() && !empty.querySelector('img,video,audio,hr,br')) empty.remove(); + } +} + +const text = String(holder.textContent || '').replace(/\s+/g, ' ').trim(); +const cjkCount = (text.match(/[\u3400-\u9fff\uf900-\ufaff\u3040-\u30ff\uac00-\ud7af]/g) || []).length; +const latinCount = (text.replace(/[\u3400-\u9fff\uf900-\ufaff\u3040-\u30ff\uac00-\ud7af]/g, ' ').match(/[\p{L}\p{N}]+/gu) || []).length; +const readingUnits = cjkCount + latinCount; +let confidence = 'low'; +if (extractionMethod === 'readability' && text.length >= 1000) confidence = 'high'; +else if (extractionMethod === 'readability' || text.length >= options.charThreshold) confidence = 'medium'; + +return { + url: location.href, + canonicalUrl, + title: parsed.title || liveDocument.title || null, + byline: parsed.byline || null, + siteName: parsed.siteName || null, + publishedAt: parsed.publishedTime || null, + language: parsed.lang || liveDocument.documentElement.lang || null, + direction: parsed.dir || liveDocument.dir || null, + excerpt: parsed.excerpt || null, + html: holder.innerHTML, + text, + textLength: text.length, + readingTimeMinutes: Math.max(1, Math.ceil(readingUnits / 250)), + extractionMethod, + confidence, + truncated, + warnings, + hiddenNodesRemoved, +}; +""" + +_TARGET_INFO_SCRIPT = r""" +const target = arguments[0]; +const selector = target && /^e\d+$/.test(target) + ? `[data-ai2apps-ref="${CSS.escape(target)}"]` : target; +const deepFind = (root, selector) => { + const found = root.querySelector(selector); + if (found) return found; + for (const host of root.querySelectorAll('*')) { + if (host.shadowRoot) { + const nested = deepFind(host.shadowRoot, selector); + if (nested) return nested; + } + } + return null; +}; +const el = selector ? deepFind(document, selector) : document.activeElement; +if (!el) return null; +return { + tag: el.tagName.toLowerCase(), + type: String(el.getAttribute('type') || '').toLowerCase(), + autocomplete: String(el.getAttribute('autocomplete') || '').toLowerCase(), + text: String(el.innerText || el.value || el.getAttribute('aria-label') || '').trim().slice(0, 300), + submits: Boolean( + el.matches('button[type="submit"],input[type="submit"],input[type="image"]') || + el.closest('form') + ), +}; +""" + +_RELOCATE_SCRIPT = r""" +const ref = arguments[0]; +const fingerprint = window.__ai2appsRefFingerprints?.[ref]; +if (!fingerprint) return {status:'missing_fingerprint'}; +const selector = [ + 'a[href]', 'button', 'input:not([type="hidden"])', 'textarea', 'select', + '[role="button"]', '[role="link"]', '[contenteditable="true"]' +].join(','); +const allRoots = (root = document) => { + const roots = [root]; + for (const el of root.querySelectorAll('*')) { + if (el.shadowRoot) roots.push(...allRoots(el.shadowRoot)); + } + return roots; +}; +const normalize = value => String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); +const tokens = value => new Set(normalize(value).split(/[^\p{L}\p{N}]+/u).filter(Boolean)); +const similarity = (left, right) => { + left = normalize(left); right = normalize(right); + if (!left || !right) return 0; + if (left === right) return 1; + const a = tokens(left), b = tokens(right); + const intersection = [...a].filter(value => b.has(value)).length; + const union = new Set([...a, ...b]).size; + return union ? intersection / union : 0; +}; +const visible = el => { + if (!el || el.closest('[hidden],[aria-hidden="true"],[inert]')) return false; + const style = getComputedStyle(el), rect = el.getBoundingClientRect(); + return style.display !== 'none' && style.visibility !== 'hidden' && + style.opacity !== '0' && rect.width > 0 && rect.height > 0; +}; +const scored = []; +for (const root of allRoots()) for (const el of root.querySelectorAll(selector)) { + if (!visible(el)) continue; + const rect = el.getBoundingClientRect(); + const candidate = { + tag: el.tagName.toLowerCase(), role: el.getAttribute('role') || '', + type: el.getAttribute('type') || '', + text: String(el.innerText || el.getAttribute('aria-label') || + el.getAttribute('placeholder') || el.value || '').slice(0, 300), + ariaLabel: el.getAttribute('aria-label') || '', + placeholder: el.getAttribute('placeholder') || '', + href: el.tagName === 'A' ? el.href : '', + }; + let score = candidate.tag === fingerprint.tag ? 3 : -5; + if (fingerprint.role) score += candidate.role === fingerprint.role ? 2 : -1; + if (fingerprint.type) score += candidate.type === fingerprint.type ? 2 : -1; + if (fingerprint.href) score += candidate.href === fingerprint.href ? 5 : -1; + score += similarity(candidate.text, fingerprint.text) * 6; + score += similarity(candidate.ariaLabel, fingerprint.ariaLabel) * 3; + score += similarity(candidate.placeholder, fingerprint.placeholder) * 3; + if (fingerprint.rect?.length === 4) { + const oldX = fingerprint.rect[0] + fingerprint.rect[2] / 2; + const oldY = fingerprint.rect[1] + fingerprint.rect[3] / 2; + const distance = Math.hypot(rect.left + rect.width / 2 - oldX, + rect.top + rect.height / 2 - oldY); + score += Math.max(0, 2 - distance / 400); + } + scored.push({el, score, candidate}); +} +scored.sort((a, b) => b.score - a.score); +const best = scored[0], second = scored[1]; +if (!best || best.score < 8) { + return {status:'not_found', bestScore:best?.score || 0}; +} +if (second && best.score - second.score < 2.5) { + return { + status:'ambiguous', bestScore:best.score, secondScore:second.score, + candidates:scored.slice(0, 3).map(item => ({ + tag:item.candidate.tag, text:item.candidate.text, score:item.score + })) + }; +} +best.el.setAttribute('data-ai2apps-ref', ref); +window.__ai2appsElementRefs?.set(best.el, ref); +window.__ai2appsRefFingerprints[ref] = { + ...fingerprint, ...best.candidate, + rect:[best.el.getBoundingClientRect().x, best.el.getBoundingClientRect().y, + best.el.getBoundingClientRect().width, best.el.getBoundingClientRect().height] +}; +return {status:'relocated', score:best.score, tag:best.candidate.tag, + text:best.candidate.text}; +""" + +_FIND_TARGET_SCRIPT = r""" +const selector = arguments[0]; +const deepFind = (root) => { + const found = root.querySelector(selector); + if (found) return found; + for (const host of root.querySelectorAll('*')) { + if (host.shadowRoot) { + const nested = deepFind(host.shadowRoot); + if (nested) return nested; + } + } + return null; +}; +return deepFind(document); +""" + +_INSTALL_STABILITY_OBSERVER_SCRIPT = r""" +if (!window.__ai2appsDomStability) { + window.__ai2appsDomStability = {lastMutation: performance.now(), count: 0}; + new MutationObserver(() => { + window.__ai2appsDomStability.lastMutation = performance.now(); + window.__ai2appsDomStability.count += 1; + }).observe(document.documentElement, {subtree:true, childList:true, attributes:true, characterData:true}); +} +return {readyState:document.readyState, + quietMs:performance.now()-window.__ai2appsDomStability.lastMutation, + mutations:window.__ai2appsDomStability.count}; +""" + + +class _BiDiEventClient: + """Small event-only BiDi client; commands remain standards-based WebDriver.""" + + def __init__(self, url: str) -> None: + self.url = url + self.events: deque[dict[str, Any]] = deque(maxlen=100) + self._socket = None + self._thread: threading.Thread | None = None + self._stop = threading.Event() + + def start(self) -> None: + try: + import websocket + except ImportError as exc: # provided by selenium + raise BrowserError( + "browser_dependency_missing", + "websocket-client is required for WebDriver BiDi events", + ) from exc + self._socket = websocket.create_connection(self.url, timeout=5) + self._socket.send( + json.dumps( + { + "id": 1, + "method": "session.subscribe", + "params": { + "events": [ + "browsingContext.navigationStarted", + "browsingContext.domContentLoaded", + "browsingContext.load", + ] + }, + } + ) + ) + response = json.loads(self._socket.recv()) + if response.get("error"): + raise BrowserError("bidi_subscribe_failed", str(response)) + self._socket.settimeout(0.5) + self._thread = threading.Thread( + target=self._listen, name="ai2apps-browser-bidi", daemon=True + ) + self._thread.start() + + def _listen(self) -> None: + while not self._stop.is_set() and self._socket is not None: + try: + message = json.loads(self._socket.recv()) + except Exception as exc: + if type(exc).__name__ == "WebSocketTimeoutException": + continue + return + if "method" in message: + params = message.get("params") or {} + self.events.append( + { + "method": message["method"], + "url": params.get("url"), + "timestamp": params.get("timestamp"), + } + ) + + def close(self) -> None: + self._stop.set() + if self._socket is not None: + with suppress(Exception): + self._socket.close() + if self._thread is not None: + self._thread.join(timeout=2) + + @property + def connected(self) -> bool: + return self._socket is not None and bool( + self._thread is not None and self._thread.is_alive() + ) + + +class ChromeBrowserBackend: + """Selenium creates the session; the session exposes a real BiDi socket.""" + + def __init__(self, config: BrowserRuntimeConfig) -> None: + self.config = config + self.driver = None + self.bidi: _BiDiEventClient | None = None + self._pointer_position: tuple[int, int] | None = None + self._download_directory: Path | None = None + self._context_refs: dict[str, tuple[tuple[int, ...], str]] = {} + self._context_frame_elements: dict[str, tuple[Any, ...]] = {} + + def set_download_directory(self, path: str | Path) -> None: + resolved = Path(path).expanduser().resolve() + resolved.mkdir(parents=True, exist_ok=True) + if self.driver is not None and resolved != self._download_directory: + raise BrowserError( + "download_directory_locked", + "Chrome must be restarted before changing its Session download directory", + ) + self._download_directory = resolved + + def start(self) -> None: + if self.driver is not None: + return + try: + from selenium import webdriver + from selenium.webdriver.chrome.options import Options + from selenium.webdriver.chrome.service import Service + except ImportError as exc: + raise BrowserError( + "browser_dependency_missing", + "Install AI2Apps with the browser extra: pip install 'ai2apps[browser]'", + ) from exc + profile = Path(self.config.profile_path).expanduser().resolve() + profile.mkdir(parents=True, exist_ok=True) + options = Options() + if self.config.binary_path: + options.binary_location = self.config.binary_path + options.web_socket_url = True + options.add_argument(f"--user-data-dir={profile}") + options.add_argument("--no-first-run") + options.add_argument("--no-default-browser-check") + if self.config.headless: + options.add_argument("--headless=new") + options.add_experimental_option( + "prefs", + { + "credentials_enable_service": False, + "profile.password_manager_enabled": False, + "download.prompt_for_download": False, + "download.directory_upgrade": True, + "safebrowsing.enabled": True, + **( + {"download.default_directory": str(self._download_directory)} + if self._download_directory is not None + else {} + ), + }, + ) + service = Service(executable_path=self.config.driver_path) + try: + self.driver = webdriver.Chrome(service=service, options=options) + self.driver.set_page_load_timeout(self.config.page_load_timeout_seconds) + bidi_url = self.driver.capabilities.get("webSocketUrl") + if not bidi_url: + raise BrowserError( + "bidi_unavailable", + "ChromeDriver did not expose a WebDriver BiDi URL", + ) + self.bidi = _BiDiEventClient(str(bidi_url)) + self.bidi.start() + except Exception: + self.stop() + raise + + @property + def bidi_connected(self) -> bool: + return self.bidi is not None and self.bidi.connected + + def recent_events(self) -> list[dict[str, Any]]: + return [] if self.bidi is None else list(self.bidi.events) + + def stop(self) -> None: + if self.bidi is not None: + self.bidi.close() + self.bidi = None + if self.driver is not None: + try: + self.driver.quit() + finally: + self.driver = None + + def navigate(self, url: str) -> None: + self._driver().get(url) + self._pointer_position = None + self._context_refs.clear() + self._context_frame_elements.clear() + + def current(self) -> tuple[str, str]: + driver = self._driver() + return driver.current_url, driver.title + + def tabs(self) -> list[dict[str, Any]]: + driver = self._driver() + active = driver.current_window_handle + result = [] + for handle in driver.window_handles: + driver.switch_to.window(handle) + result.append( + { + "id": handle, + "url": driver.current_url, + "title": driver.title, + "active": handle == active, + } + ) + if active in driver.window_handles: + driver.switch_to.window(active) + return result + + def open_tab(self, url: str | None = None) -> str: + driver = self._driver() + driver.switch_to.new_window("tab") + self._pointer_position = None + if url: + driver.get(url) + return driver.current_window_handle + + def switch_tab(self, tab_id: str) -> None: + driver = self._driver() + if tab_id not in driver.window_handles: + raise BrowserError("tab_not_found", f"Browser tab not found: {tab_id}") + driver.switch_to.window(tab_id) + self._pointer_position = None + + def close_tab(self, tab_id: str) -> str: + driver = self._driver() + handles = driver.window_handles + if tab_id not in handles: + raise BrowserError("tab_not_found", f"Browser tab not found: {tab_id}") + if len(handles) == 1: + raise BrowserError("last_tab", "The final browser tab cannot be closed") + driver.switch_to.window(tab_id) + driver.close() + remaining = driver.window_handles + driver.switch_to.window(remaining[-1]) + self._pointer_position = None + return driver.current_window_handle + + def detect_authentication(self) -> AuthenticationChallenge | None: + from selenium.webdriver.common.by import By + + driver = self._driver() + driver.switch_to.default_content() + + def inspect() -> dict[str, Any] | None: + result = driver.execute_script(_AUTH_SCRIPT) + if result: + return result + for frame in driver.find_elements(By.CSS_SELECTOR, "iframe,frame"): + switched = False + try: + driver.switch_to.frame(frame) + switched = True + nested = inspect() + if nested: + return nested + except Exception: + pass + finally: + if switched: + driver.switch_to.parent_frame() + return None + + try: + result = inspect() + finally: + driver.switch_to.default_content() + if not result: + return None + return AuthenticationChallenge(str(result["kind"]), str(result["reason"])) + + def _rendered_text_all_contexts(self) -> str: + from selenium.webdriver.common.by import By + + driver = self._driver() + driver.switch_to.default_content() + parts: list[str] = [] + + def collect() -> None: + value = driver.execute_script( + """ + const roots=[document]; + for(let i=0;i root.body?.innerText || root.host?.shadowRoot?.textContent || '') + .filter(Boolean).join(' '); + """ + ) + if value: + parts.append(str(value)) + for frame in driver.find_elements(By.CSS_SELECTOR, "iframe,frame"): + switched = False + try: + driver.switch_to.frame(frame) + switched = True + collect() + except Exception: + pass + finally: + if switched: + driver.switch_to.parent_frame() + + try: + collect() + finally: + driver.switch_to.default_content() + return " ".join(parts) + + def snapshot( + self, + *, + max_items: int, + max_text: int, + html_mode: str, + max_html: int, + ) -> BrowserSnapshot: + from selenium.webdriver.common.by import By + + driver = self._driver() + driver.switch_to.default_content() + page_url, page_title = driver.current_url, driver.title + items: list[dict[str, Any]] = [] + text_parts: list[str] = [] + frame_html: list[str] = [] + self._context_refs = {} + self._context_frame_elements = {} + + def capture( + path: tuple[int, ...], + offset_x: float, + offset_y: float, + frame_chain: tuple[Any, ...], + ) -> dict: + remaining_items = max(0, max_items - len(items)) + remaining_text = max(0, max_text - sum(len(value) for value in text_parts)) + result = driver.execute_script( + _SNAPSHOT_SCRIPT, + { + "maxItems": remaining_items, + "maxText": remaining_text, + "htmlMode": html_mode, + "maxHtml": max_html, + }, + ) + context_name = ".".join(str(value) for value in path) + prefix = f"f{context_name}:" if path else "" + context_html = str(result["html"]) + for raw in result["items"]: + item = dict(raw) + local_ref = str(item["ref"]) + public_ref = prefix + local_ref + self._context_refs[public_ref] = (path, local_ref) + self._context_frame_elements[public_ref] = frame_chain + item["ref"] = public_ref + item["context"] = "top" if not path else f"frame:{context_name}" + rect = list(item.get("rect") or ()) + if len(rect) == 4: + rect[0] = round(float(rect[0]) + offset_x, 1) + rect[1] = round(float(rect[1]) + offset_y, 1) + item["rect"] = rect + items.append(item) + if prefix: + context_html = context_html.replace( + f'data-ai2apps-ref="{local_ref}"', + f'data-ai2apps-ref="{public_ref}"', + ) + if result.get("text"): + text_parts.append(str(result["text"])) + frames = driver.find_elements(By.CSS_SELECTOR, "iframe,frame") + for index, frame in enumerate(frames): + if len(items) >= max_items: + break + rect = driver.execute_script( + "const r=arguments[0].getBoundingClientRect();" + "return {x:r.x,y:r.y,w:r.width,h:r.height};", + frame, + ) + switched = False + try: + driver.switch_to.frame(frame) + switched = True + child_path = path + (index,) + child = capture( + child_path, + offset_x + float(rect["x"]), + offset_y + float(rect["y"]), + frame_chain + (frame,), + ) + child_name = ".".join(str(value) for value in child_path) + frame_html.append( + f'' + ) + except Exception: + # A detached or browser-internal frame is simply unavailable. + pass + finally: + if switched: + driver.switch_to.parent_frame() + return result | {"html": context_html} + + result = capture((), 0, 0, ()) + driver.switch_to.default_content() + html = str(result["html"]) + if frame_html: + html += "" + "".join(frame_html) + "" + html_truncated = bool(result["htmlTruncated"]) + if len(html) > max_html and html_mode != "full": + html = html[:max_html] + "" + html_truncated = True + return BrowserSnapshot( + url=page_url, + title=page_title, + items=tuple(items), + text=" ".join(text_parts).replace(" ", " ")[:max_text], + html=html, + html_mode=result["htmlMode"], + html_truncated=html_truncated, + ) + + def read_article( + self, + *, + mode: str, + selector: str | None, + include_images: bool, + include_links: bool, + max_chars: int, + char_threshold: int, + max_elements: int, + ) -> BrowserArticle: + driver = self._driver() + loaded = driver.execute_script( + "return typeof globalThis.__ai2appsReadability === 'function';" + ) + if not loaded: + driver.execute_script(_READABILITY_SOURCE + "\nsetReadablility();") + try: + result = driver.execute_script( + _ARTICLE_SCRIPT, + { + "mode": mode, + "selector": selector, + "includeImages": include_images, + "includeLinks": include_links, + "maxChars": max_chars, + "charThreshold": char_threshold, + "maxElements": max_elements, + }, + ) + except Exception as exc: + message = str(exc) + code = "article_extraction_failed" + for known in ( + "invalid_article_selector", + "article_selector_not_found", + "article_not_found", + ): + if known in message: + code = known + break + raise BrowserError(code, message) from exc + return BrowserArticle( + url=str(result["url"]), + canonical_url=result.get("canonicalUrl"), + title=result.get("title"), + byline=result.get("byline"), + site_name=result.get("siteName"), + published_at=result.get("publishedAt"), + language=result.get("language"), + direction=result.get("direction"), + excerpt=result.get("excerpt"), + html=str(result.get("html") or ""), + text=str(result.get("text") or ""), + text_length=int(result.get("textLength") or 0), + reading_time_minutes=int(result.get("readingTimeMinutes") or 1), + extraction_method=str(result.get("extractionMethod") or "unknown"), + confidence=str(result.get("confidence") or "low"), + truncated=bool(result.get("truncated")), + warnings=tuple(str(item) for item in result.get("warnings", ())), + hidden_nodes_removed=int(result.get("hiddenNodesRemoved") or 0), + ) + + def target_info(self, target: str | None) -> dict[str, Any]: + if target is None: + self._driver().switch_to.default_content() + result = self._driver().execute_script(_TARGET_INFO_SCRIPT, None) + else: + with self._element_context(target) as (element, _x, _y): + result = self._driver().execute_script( + _TARGET_INFO_SCRIPT, self._local_target(target) + ) + if result is None: + result = self._driver().execute_script( + "return arguments[0] ? {tag:arguments[0].tagName.toLowerCase()," + "type:String(arguments[0].type||'').toLowerCase()," + "autocomplete:String(arguments[0].autocomplete||'').toLowerCase()," + "text:String(arguments[0].innerText||arguments[0].value||'').slice(0,300)," + "submits:Boolean(arguments[0].form)} : null;", + element, + ) + if result is None: + raise BrowserError( + "target_not_found", f"Browser target not found: {target or 'active element'}" + ) + return dict(result) + + @staticmethod + def _is_element_ref(target: str) -> bool: + return bool(re.fullmatch(r"(?:f\d+(?:\.\d+)*:)?e\d+", target)) + + def _local_target(self, target: str) -> str: + return self._context_refs.get(target, ((), target))[1] + + def _switch_to_context( + self, path: tuple[int, ...], frame_chain: tuple[Any, ...] = () + ) -> tuple[float, float]: + from selenium.webdriver.common.by import By + + driver = self._driver() + driver.switch_to.default_content() + offset_x = offset_y = 0.0 + try: + for depth, index in enumerate(path): + if frame_chain: + frame = frame_chain[depth] + else: + frames = driver.find_elements(By.CSS_SELECTOR, "iframe,frame") + if index >= len(frames): + raise BrowserError( + "frame_not_found", f"Frame path is stale: {path}" + ) + frame = frames[index] + rect = driver.execute_script( + "const r=arguments[0].getBoundingClientRect();return {x:r.x,y:r.y};", + frame, + ) + offset_x += float(rect["x"]) + offset_y += float(rect["y"]) + driver.switch_to.frame(frame) + except BrowserError: + raise + except Exception as exc: + raise BrowserError( + "frame_not_found", + "The iframe containing this target was replaced or detached; take a new snapshot", + ) from exc + return offset_x, offset_y + + @contextmanager + def _element_context(self, target: str): + path, local_target = self._context_refs.get(target, ((), target)) + frame_chain = self._context_frame_elements.get(target, ()) + offset_x, offset_y = self._switch_to_context(path, frame_chain) + try: + yield self._element_in_current(local_target), offset_x, offset_y + finally: + self._driver().switch_to.default_content() + + def _element_in_current(self, target: str): + from selenium.common.exceptions import NoSuchElementException + + selector = ( + f'[data-ai2apps-ref="{target}"]' + if re.fullmatch(r"e\d+", target) + else target + ) + self._last_relocation = None + try: + element = self._driver().execute_script(_FIND_TARGET_SCRIPT, selector) + if element is None: + raise NoSuchElementException(selector) + return element + except NoSuchElementException as exc: + if not re.fullmatch(r"e\d+", target): + raise BrowserError( + "target_not_found", f"Browser target not found: {target}" + ) from exc + relocation = self._driver().execute_script(_RELOCATE_SCRIPT, target) + status = relocation.get("status") + if status == "ambiguous": + raise BrowserError( + "target_relocation_ambiguous", + "Multiple elements match the stale reference: " + + json.dumps(relocation.get("candidates", []), ensure_ascii=False), + ) from exc + if status != "relocated": + raise BrowserError( + "target_not_found", + f"Browser target {target} is stale and no high-confidence replacement exists", + ) from exc + self._last_relocation = dict(relocation) + element = self._driver().execute_script(_FIND_TARGET_SCRIPT, selector) + if element is None: + raise BrowserError( + "target_not_found", f"Relocated browser target disappeared: {target}" + ) from exc + return element + + def wait_for( + self, + *, + condition: str, + target: str | None, + state: str, + text: str | None, + url_contains: str | None, + timeout_ms: int, + poll_ms: int, + stable_ms: int, + ) -> dict[str, Any]: + start = time.monotonic() + deadline = start + timeout_ms / 1000 + driver = self._driver() + detail: dict[str, Any] = {} + last_error: str | None = None + while True: + try: + satisfied = False + if condition == "element": + try: + with self._element_context(target or "") as ( + element, + _offset_x, + _offset_y, + ): + present = True + visible = element.is_displayed() + enabled = visible and element.is_enabled() + except BrowserError as exc: + element = None + last_error = str(exc) + present = visible = enabled = False + satisfied = { + "present": present, + "visible": visible, + "hidden": not visible, + "enabled": enabled, + "clickable": enabled, + "absent": not present, + }[state] + detail = { + "state": state, + "present": present, + "visible": bool(visible), + "enabled": bool(enabled), + "relocation": self._last_relocation, + } + elif condition == "text": + if target: + with self._element_context(target) as ( + element, + _offset_x, + _offset_y, + ): + haystack = ( + element.text or element.get_attribute("value") or "" + ) + else: + haystack = self._rendered_text_all_contexts() + satisfied = (text or "") in haystack + detail = {"text": text, "target": target} + elif condition == "url": + current_url = driver.current_url + satisfied = (url_contains or "") in current_url + detail = {"url": current_url, "url_contains": url_contains} + else: + stability = driver.execute_script( + _INSTALL_STABILITY_OBSERVER_SCRIPT + ) + satisfied = ( + stability["readyState"] == "complete" + and stability["quietMs"] >= stable_ms + ) + detail = { + "ready_state": stability["readyState"], + "quiet_ms": round(stability["quietMs"]), + "mutations": stability["mutations"], + } + if satisfied: + return { + "satisfied": True, + "condition": condition, + "elapsed_ms": round((time.monotonic() - start) * 1000), + "detail": detail, + } + except Exception as exc: # DOM may be replaced between poll operations + last_error = str(exc) + if time.monotonic() >= deadline: + return { + "satisfied": False, + "condition": condition, + "elapsed_ms": round((time.monotonic() - start) * 1000), + "detail": detail, + "last_error": last_error, + } + time.sleep(poll_ms / 1000) + + def _pointer_destination( + self, + *, + target: str | None, + x: int | None = None, + y: int | None = None, + ) -> tuple[int, int, int, int]: + driver = self._driver() + if target is not None: + with self._element_context(target) as (element, offset_x, offset_y): + result = driver.execute_script( + """ + arguments[0].scrollIntoView({block:'center', inline:'center'}); + const rect = arguments[0].getBoundingClientRect(); + return {x: rect.left + rect.width / 2, y: rect.top + rect.height / 2}; + """, + element, + ) + x = round(float(result["x"]) + offset_x) + y = round(float(result["y"]) + offset_y) + viewport = driver.execute_script( + "return {width: innerWidth, height: innerHeight};" + ) + width, height = int(viewport["width"]), int(viewport["height"]) + else: + viewport = driver.execute_script( + "return {width: innerWidth, height: innerHeight};" + ) + width, height = int(viewport["width"]), int(viewport["height"]) + if x is None or y is None: + raise BrowserError( + "pointer_destination_required", "Provide a target or viewport x/y" + ) + return ( + max(0, min(int(x), max(0, width - 1))), + max(0, min(int(y), max(0, height - 1))), + width, + height, + ) + + def move_pointer( + self, + *, + target: str | None, + x: int | None = None, + y: int | None = None, + duration_ms: int | None = None, + ) -> dict[str, int]: + from selenium.webdriver.remote.command import Command + + driver = self._driver() + end_x, end_y, width, height = self._pointer_destination( + target=target, x=x, y=y + ) + if self._pointer_position is None: + self._pointer_position = (width // 2, height // 2) + start_x, start_y = self._pointer_position + distance = math.hypot(end_x - start_x, end_y - start_y) + total_ms = duration_ms or round(max(180, min(1200, 180 + distance * 0.75))) + steps = max(8, min(60, round(total_ms / 16))) + perpendicular_x = -(end_y - start_y) + perpendicular_y = end_x - start_x + norm = max(distance, 1.0) + curve = min(48.0, distance * 0.08) + direction = -1 if (start_x + start_y + end_x + end_y) % 2 else 1 + actions = [] + previous = (start_x, start_y) + for index in range(1, steps + 1): + t = index / steps + eased = t * t * (3 - 2 * t) + bend = math.sin(math.pi * t) * curve * direction + point_x = start_x + (end_x - start_x) * eased + perpendicular_x / norm * bend + point_y = start_y + (end_y - start_y) * eased + perpendicular_y / norm * bend + point = ( + max(0, min(round(point_x), max(0, width - 1))), + max(0, min(round(point_y), max(0, height - 1))), + ) + if point == previous and index != steps: + continue + actions.append( + { + "type": "pointerMove", + "duration": max(5, round(total_ms / steps)), + "x": point[0], + "y": point[1], + "origin": "viewport", + } + ) + previous = point + driver.execute( + Command.W3C_ACTIONS, + { + "actions": [ + { + "type": "pointer", + "id": "ai2apps-mouse", + "parameters": {"pointerType": "mouse"}, + "actions": actions, + } + ] + }, + ) + self._pointer_position = (end_x, end_y) + return {"x": end_x, "y": end_y, "duration_ms": total_ms} + + def hover(self, target: str, *, duration_ms: int | None = None) -> dict[str, int]: + result = self.move_pointer(target=target, duration_ms=duration_ms) + time.sleep(0.08) + return result + + def click(self, target: str, *, duration_ms: int | None = None) -> None: + from selenium.webdriver.remote.command import Command + + self.move_pointer(target=target, duration_ms=duration_ms) + self._driver().execute( + Command.W3C_ACTIONS, + { + "actions": [ + { + "type": "pointer", + "id": "ai2apps-mouse", + "parameters": {"pointerType": "mouse"}, + "actions": [ + {"type": "pointerDown", "button": 0}, + { + "type": "pause", + "duration": random.SystemRandom().randint(45, 110), + }, + {"type": "pointerUp", "button": 0}, + ], + } + ] + }, + ) + + def type_text( + self, + target: str, + text: str, + *, + clear: bool, + input_mode: str = "natural", + delay_ms: int | None = None, + ) -> None: + from selenium.webdriver.common.keys import Keys + + with self._element_context(target) as (element, _offset_x, _offset_y): + if clear: + if input_mode == "natural": + select_modifier = ( + Keys.META if platform.system() == "Darwin" else Keys.CONTROL + ) + element.send_keys(select_modifier, "a") + element.send_keys(Keys.BACKSPACE) + else: + element.clear() + if input_mode == "instant": + element.send_keys(text) + return + randomizer = random.SystemRandom() + base_delay = max(0, min(delay_ms if delay_ms is not None else 32, 500)) + for character in text: + element.send_keys(character) + factor = 1.8 if character in " .,;:!?\n" else 1.0 + jitter = randomizer.uniform(0.65, 1.35) + time.sleep(base_delay * factor * jitter / 1000) + + @staticmethod + def _key_value(key: str) -> str: + from selenium.webdriver.common.keys import Keys + + aliases = { + "COMMAND": "META", + "CMD": "META", + "CTRL": "CONTROL", + "ESC": "ESCAPE", + "RETURN": "ENTER", + "ARROWDOWN": "ARROW_DOWN", + "ARROWUP": "ARROW_UP", + "ARROWLEFT": "ARROW_LEFT", + "ARROWRIGHT": "ARROW_RIGHT", + "PAGEDOWN": "PAGE_DOWN", + "PAGEUP": "PAGE_UP", + "SPACE": "SPACE", + } + normalized = aliases.get(key.upper().replace("-", "_"), key.upper().replace("-", "_")) + value = getattr(Keys, normalized, None) + if value is not None: + return value + if len(key) == 1: + return key + raise BrowserError("unsupported_key", f"Unsupported key: {key}") + + def key_press( + self, + *, + key: str, + modifiers: tuple[str, ...], + target: str | None, + repeat: int, + ) -> None: + from selenium.webdriver.common.action_chains import ActionChains + + @contextmanager + def action_context(): + if target: + with self._element_context(target) as value: + yield value[0] + else: + self._driver().switch_to.default_content() + yield None + + with action_context() as element: + actions = ActionChains(self._driver()) + if element is not None: + self._driver().execute_script( + "arguments[0].focus({preventScroll:false});", element + ) + modifier_values = [self._key_value(value) for value in modifiers] + for value in modifier_values: + actions.key_down(value) + for _ in range(repeat): + actions.send_keys(self._key_value(key)) + for value in reversed(modifier_values): + actions.key_up(value) + actions.perform() + + def clipboard_action(self, action: str, *, target: str | None) -> None: + modifier = "META" if platform.system() == "Darwin" else "CONTROL" + key = {"copy": "c", "cut": "x", "paste": "v"}[action] + self.key_press(key=key, modifiers=(modifier,), target=target, repeat=1) + + def upload_file(self, target: str, path: str | Path) -> None: + with self._element_context(target) as (element, _offset_x, _offset_y): + if str(element.get_attribute("type") or "").lower() != "file": + raise BrowserError("not_file_input", "Upload target is not a file input") + element.send_keys(str(Path(path).resolve(strict=True))) + + def staged_downloads(self, *, wait_ms: int = 0) -> dict[str, Any]: + if self._download_directory is None: + raise BrowserError( + "downloads_unavailable", "No Session download directory is configured" + ) + deadline = time.monotonic() + wait_ms / 1000 + while True: + entries = [ + item + for item in self._download_directory.iterdir() + if item.is_file() and not item.is_symlink() + ] + in_progress = [item for item in entries if item.name.endswith(".crdownload")] + complete = [item for item in entries if not item.name.endswith(".crdownload")] + if complete or (entries and not in_progress) or time.monotonic() >= deadline: + return { + "complete": [ + { + "name": item.name, + "size_bytes": item.stat().st_size, + "modified_at": item.stat().st_mtime, + } + for item in sorted(complete, key=lambda value: value.stat().st_mtime) + ], + "in_progress": [item.name for item in in_progress], + } + time.sleep(0.1) + + def scroll(self, delta_y: int) -> None: + self._driver().execute_script("window.scrollBy(0, arguments[0])", delta_y) + + def screenshot(self) -> str: + return self._driver().get_screenshot_as_base64() + + def _driver(self): + if self.driver is None: + raise BrowserError("browser_not_running", "Chrome is not running") + return self.driver diff --git a/ai2apps/browser/manager.py b/ai2apps/browser/manager.py new file mode 100644 index 00000000..f70ddb37 --- /dev/null +++ b/ai2apps/browser/manager.py @@ -0,0 +1,800 @@ +"""Single-user browser ownership and mandatory authentication handoff.""" + +from __future__ import annotations + +import asyncio +import re +from difflib import SequenceMatcher +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +from .article import article_html_to_markdown, canonical_article_html +from .models import BrowserControlState, BrowserError, BrowserRuntimeStatus + +_COMMIT_TEXT = re.compile( + r"\b(publish|post|send|submit|delete|remove|buy|purchase|checkout|pay|confirm)\b|" + r"发布|发送|提交|删除|购买|付款|支付|确认", + re.IGNORECASE, +) +_SENSITIVE_AUTOCOMPLETE = {"current-password", "new-password", "one-time-code"} + + +class BrowserManager: + def __init__(self, backend, workspace=None) -> None: + self.backend = backend + self.workspace = workspace + self.status = BrowserRuntimeStatus() + self._lock = asyncio.Lock() + self._io_session_id: str | None = None + self._observations: dict[str, dict[str, Any]] = {} + + async def _active_tab_id(self) -> str: + tabs = await asyncio.to_thread(self.backend.tabs) + active = next((item for item in tabs if item.get("active")), None) + if active is None: + raise BrowserError("active_tab_missing", "No active browser tab") + return str(active["id"]) + + @staticmethod + def _observation_value(snapshot) -> dict[str, Any]: + return { + "url": snapshot.url, + "title": snapshot.title, + "items": {str(item["ref"]): dict(item) for item in snapshot.items}, + "text": snapshot.text, + } + + @staticmethod + def _text_changes(before: str, after: str) -> list[dict[str, str]]: + if before == after: + return [] + if after.startswith(before): + return [{"operation": "insert", "before": "", "after": after[len(before) :][:1000]}] + if before.startswith(after): + return [{"operation": "delete", "before": before[len(after) :][:1000], "after": ""}] + changes = [] + matcher = SequenceMatcher(None, before, after) + for operation, left_start, left_end, right_start, right_end in matcher.get_opcodes(): + if operation == "equal": + continue + changes.append( + { + "operation": operation, + "before": before[left_start:left_end][:1000], + "after": after[right_start:right_end][:1000], + } + ) + if len(changes) >= 12: + break + return changes + + async def _prepare_session_io(self, session_id: str | None) -> None: + if self.workspace is None or session_id is None: + return + if self._io_session_id not in {None, session_id}: + raise BrowserError( + "browser_io_in_use", "Browser file staging belongs to another Session" + ) + directory = await asyncio.to_thread( + self.workspace.browser_download_directory, session_id + ) + await asyncio.to_thread(self.backend.set_download_directory, directory) + self._io_session_id = session_id + + async def start(self, *, session_id: str | None) -> dict[str, Any]: + async with self._lock: + self._claim(session_id) + if self.status.state is BrowserControlState.STOPPED: + await self._prepare_session_io(session_id) + await asyncio.to_thread(self.backend.start) + self.status.state = BrowserControlState.AGENT_CONTROL + await self._refresh() + return self.status.to_dict() + + async def close(self) -> dict[str, Any]: + async with self._lock: + await asyncio.to_thread(self.backend.stop) + self.status = BrowserRuntimeStatus() + self._io_session_id = None + self._observations.clear() + return self.status.to_dict() + + async def navigate(self, url: str, *, session_id: str | None) -> dict[str, Any]: + async with self._lock: + await self._ensure_agent_control(session_id) + self._validate_url(url) + await asyncio.to_thread(self.backend.navigate, url) + await self._refresh() + challenge = await self._detect_challenge() + return { + **self.status.to_dict(), + "user_action_required": challenge is not None, + } + + async def list_tabs(self, *, session_id: str | None) -> dict[str, Any]: + async with self._lock: + await self._ensure_agent_control(session_id) + tabs = await asyncio.to_thread(self.backend.tabs) + await self._refresh() + return {**self.status.to_dict(), "tabs": tabs} + + async def open_tab( + self, *, session_id: str | None, url: str | None = None + ) -> dict[str, Any]: + if url: + self._validate_url(url) + async with self._lock: + await self._ensure_agent_control(session_id) + tab_id = await asyncio.to_thread(self.backend.open_tab, url) + challenge = await self._detect_challenge() + await self._refresh() + return { + **self.status.to_dict(), + "opened_tab": tab_id, + "user_action_required": challenge is not None, + } + + async def switch_tab( + self, tab_id: str, *, session_id: str | None + ) -> dict[str, Any]: + async with self._lock: + await self._ensure_agent_control(session_id) + await asyncio.to_thread(self.backend.switch_tab, tab_id) + challenge = await self._detect_challenge() + await self._refresh() + return { + **self.status.to_dict(), + "active_tab": tab_id, + "user_action_required": challenge is not None, + } + + async def close_tab( + self, tab_id: str, *, session_id: str | None + ) -> dict[str, Any]: + async with self._lock: + await self._ensure_agent_control(session_id) + active = await asyncio.to_thread(self.backend.close_tab, tab_id) + await self._refresh() + return {**self.status.to_dict(), "closed_tab": tab_id, "active_tab": active} + + async def snapshot( + self, + *, + session_id: str | None, + max_items: int = 150, + max_text: int = 20_000, + html_mode: str = "visible", + max_html: int = 60_000, + ) -> dict[str, Any]: + async with self._lock: + await self._ensure_agent_control(session_id) + if await self._detect_challenge() is not None: + return {**self.status.to_dict(), "user_action_required": True} + snapshot = await asyncio.to_thread( + self.backend.snapshot, + max_items=max_items, + max_text=max_text, + html_mode=html_mode, + max_html=max_html, + ) + tab_id = await self._active_tab_id() + self._observations[tab_id] = self._observation_value(snapshot) + await self._refresh() + return { + **self.status.to_dict(), + "snapshot": { + "url": snapshot.url, + "title": snapshot.title, + "items": list(snapshot.items), + "text": snapshot.text, + "html": snapshot.html, + "html_mode": snapshot.html_mode, + "html_truncated": snapshot.html_truncated, + }, + } + + async def observe_changes( + self, + *, + session_id: str | None, + reset: bool = False, + max_items: int = 200, + max_text: int = 20_000, + ) -> dict[str, Any]: + async with self._lock: + await self._ensure_agent_control(session_id) + if await self._detect_challenge() is not None: + return {**self.status.to_dict(), "user_action_required": True} + tab_id = await self._active_tab_id() + snapshot = await asyncio.to_thread( + self.backend.snapshot, + max_items=max_items, + max_text=max_text, + html_mode="visible", + max_html=1_000, + ) + current = self._observation_value(snapshot) + previous = None if reset else self._observations.get(tab_id) + self._observations[tab_id] = current + if previous is None: + changes: dict[str, Any] = { + "initial": True, + "added": list(current["items"].values()), + "removed": [], + "changed": [], + "text_changes": [], + } + else: + old_items, new_items = previous["items"], current["items"] + added = [new_items[ref] for ref in new_items.keys() - old_items.keys()] + removed = [old_items[ref] for ref in old_items.keys() - new_items.keys()] + changed = [] + for ref in old_items.keys() & new_items.keys(): + fields = {} + for field in ("text", "href", "disabled", "role", "type", "rect"): + if old_items[ref].get(field) != new_items[ref].get(field): + fields[field] = { + "before": old_items[ref].get(field), + "after": new_items[ref].get(field), + } + if fields: + changed.append({"ref": ref, "fields": fields}) + changes = { + "initial": False, + "url_changed": previous["url"] != current["url"], + "title_changed": previous["title"] != current["title"], + "added": added, + "removed": removed, + "changed": changed, + "text_changes": self._text_changes(previous["text"], current["text"]), + } + await self._refresh() + return { + **self.status.to_dict(), + "observation": { + "tab_id": tab_id, + "url": current["url"], + "title": current["title"], + "counts": { + "added": len(changes["added"]), + "removed": len(changes["removed"]), + "changed": len(changes["changed"]), + "text_changes": len(changes["text_changes"]), + }, + **changes, + }, + } + + async def read_article( + self, + *, + session_id: str | None, + output_format: str = "markdown", + mode: str = "auto", + selector: str | None = None, + include_images: bool = True, + include_links: bool = True, + max_chars: int = 100_000, + char_threshold: int = 500, + max_elements: int = 100_000, + ) -> dict[str, Any]: + if output_format not in {"markdown", "html", "both"}: + raise BrowserError("invalid_article_format", output_format) + if mode not in {"auto", "strict"}: + raise BrowserError("invalid_article_mode", mode) + async with self._lock: + await self._ensure_agent_control(session_id) + if await self._detect_challenge() is not None: + return {**self.status.to_dict(), "user_action_required": True} + article = await asyncio.to_thread( + self.backend.read_article, + mode=mode, + selector=selector, + include_images=include_images, + include_links=include_links, + max_chars=max_chars, + char_threshold=char_threshold, + max_elements=max_elements, + ) + html = canonical_article_html( + article.html, + title=article.title, + byline=article.byline, + published_at=article.published_at, + language=article.language, + direction=article.direction, + ) + markdown = None + if output_format in {"markdown", "both"}: + markdown = await asyncio.to_thread( + article_html_to_markdown, + article.html, + title=article.title, + byline=article.byline, + published_at=article.published_at, + ) + payload: dict[str, Any] = { + "url": article.url, + "canonical_url": article.canonical_url, + "title": article.title, + "byline": article.byline, + "site_name": article.site_name, + "published_at": article.published_at, + "language": article.language, + "direction": article.direction, + "excerpt": article.excerpt, + "text_length": article.text_length, + "reading_time_minutes": article.reading_time_minutes, + "extraction_method": article.extraction_method, + "confidence": article.confidence, + "truncated": article.truncated, + "warnings": list(article.warnings), + "hidden_nodes_removed": article.hidden_nodes_removed, + "format": output_format, + } + if output_format == "html": + payload["content"] = html + elif output_format == "markdown": + payload["content"] = markdown + else: + payload["content_html"] = html + payload["content_markdown"] = markdown + await self._refresh() + return {**self.status.to_dict(), "article": payload} + + async def click( + self, + target: str, + *, + session_id: str | None, + commit: bool, + duration_ms: int | None = None, + ) -> dict[str, Any]: + async with self._lock: + await self._ensure_agent_control(session_id) + tabs_before = { + item["id"] for item in await asyncio.to_thread(self.backend.tabs) + } + info = await asyncio.to_thread(self.backend.target_info, target) + if self._sensitive(info): + self._require_user( + "login", "Authentication fields require user control" + ) + return {**self.status.to_dict(), "user_action_required": True} + commit_target = bool(_COMMIT_TEXT.search(info.get("text", ""))) + if commit_target and not commit: + raise BrowserError( + "commit_confirmation_required", + "This control may create an external side effect; retry with commit=true after user approval", + ) + await asyncio.to_thread( + self.backend.click, target, duration_ms=duration_ms + ) + await asyncio.sleep(0.1) + tabs_after = await asyncio.to_thread(self.backend.tabs) + new_tabs = [item for item in tabs_after if item["id"] not in tabs_before] + challenge = await self._detect_challenge() + await self._refresh() + return { + **self.status.to_dict(), + "clicked": True, + "commit": commit_target, + "new_tabs": new_tabs, + "user_action_required": challenge is not None, + } + + async def hover( + self, + target: str, + *, + session_id: str | None, + duration_ms: int | None = None, + ) -> dict[str, Any]: + async with self._lock: + await self._ensure_agent_control(session_id) + result = await asyncio.to_thread( + self.backend.hover, target, duration_ms=duration_ms + ) + await self._refresh() + return {**self.status.to_dict(), "hovered": True, "pointer": result} + + async def move_pointer( + self, + *, + session_id: str | None, + target: str | None = None, + x: int | None = None, + y: int | None = None, + duration_ms: int | None = None, + ) -> dict[str, Any]: + if target is None and (x is None or y is None): + raise BrowserError( + "pointer_destination_required", "Provide target or both x and y" + ) + async with self._lock: + await self._ensure_agent_control(session_id) + result = await asyncio.to_thread( + self.backend.move_pointer, + target=target, + x=x, + y=y, + duration_ms=duration_ms, + ) + await self._refresh() + return {**self.status.to_dict(), "moved": True, "pointer": result} + + async def wait_for( + self, + *, + session_id: str | None, + condition: str, + target: str | None = None, + state: str = "visible", + text: str | None = None, + url_contains: str | None = None, + timeout_ms: int = 10_000, + poll_ms: int = 100, + stable_ms: int = 500, + ) -> dict[str, Any]: + allowed_conditions = {"element", "text", "url", "page_stable"} + allowed_states = { + "present", + "visible", + "hidden", + "enabled", + "clickable", + "absent", + } + if condition not in allowed_conditions: + raise BrowserError("invalid_wait_condition", condition) + if condition == "element" and (not target or state not in allowed_states): + raise BrowserError( + "invalid_element_wait", "Element waits require target and valid state" + ) + if condition == "text" and text is None: + raise BrowserError("invalid_text_wait", "Text waits require text") + if condition == "url" and url_contains is None: + raise BrowserError("invalid_url_wait", "URL waits require url_contains") + async with self._lock: + await self._ensure_agent_control(session_id) + result = await asyncio.to_thread( + self.backend.wait_for, + condition=condition, + target=target, + state=state, + text=text, + url_contains=url_contains, + timeout_ms=timeout_ms, + poll_ms=poll_ms, + stable_ms=stable_ms, + ) + if not result["satisfied"]: + diagnostic = await asyncio.to_thread( + self.backend.snapshot, + max_items=80, + max_text=8_000, + html_mode="visible", + max_html=20_000, + ) + result["diagnostic_snapshot"] = { + "url": diagnostic.url, + "title": diagnostic.title, + "items": list(diagnostic.items), + "text": diagnostic.text, + "html": diagnostic.html, + "html_truncated": diagnostic.html_truncated, + } + await self._refresh() + return {**self.status.to_dict(), "wait": result} + + async def type_text( + self, + target: str, + text: str, + *, + session_id: str | None, + clear: bool, + input_mode: str = "natural", + delay_ms: int | None = None, + ) -> dict[str, Any]: + if input_mode not in {"natural", "instant"}: + raise BrowserError("invalid_input_mode", input_mode) + async with self._lock: + await self._ensure_agent_control(session_id) + info = await asyncio.to_thread(self.backend.target_info, target) + if self._sensitive(info): + self._require_user( + "login", + "Passwords and verification codes must be entered by the user", + ) + return {**self.status.to_dict(), "user_action_required": True} + await asyncio.to_thread( + self.backend.type_text, + target, + text, + clear=clear, + input_mode=input_mode, + delay_ms=delay_ms, + ) + await self._refresh() + return { + **self.status.to_dict(), + "typed": True, + "input_mode": input_mode, + } + + async def key_press( + self, + key: str, + *, + session_id: str | None, + modifiers: tuple[str, ...] = (), + target: str | None = None, + repeat: int = 1, + commit: bool = False, + ) -> dict[str, Any]: + async with self._lock: + await self._ensure_agent_control(session_id) + info = await asyncio.to_thread(self.backend.target_info, target) + if self._sensitive(info): + self._require_user( + "login", "Authentication fields require user keyboard control" + ) + return {**self.status.to_dict(), "user_action_required": True} + normalized = key.upper().replace("-", "_") + normalized_modifiers = { + value.upper().replace("-", "_") for value in modifiers + } + if normalized.lower() in {"c", "x", "v"} and normalized_modifiers & { + "META", + "COMMAND", + "CMD", + "CONTROL", + "CTRL", + }: + raise BrowserError( + "clipboard_capability_required", + "Use browser.clipboard for copy, cut, and paste shortcuts", + ) + may_submit = normalized in {"ENTER", "RETURN"} and info.get("submits") + consequential = bool(_COMMIT_TEXT.search(info.get("text", ""))) + if (may_submit or consequential) and not commit: + raise BrowserError( + "commit_confirmation_required", + "This key may submit a form or activate a consequential control; retry with commit=true after user approval", + ) + await asyncio.to_thread( + self.backend.key_press, + key=key, + modifiers=modifiers, + target=target, + repeat=repeat, + ) + challenge = await self._detect_challenge() + await self._refresh() + return { + **self.status.to_dict(), + "key_pressed": key, + "repeat": repeat, + "commit": bool(may_submit or consequential), + "user_action_required": challenge is not None, + } + + async def clipboard_action( + self, + action: str, + *, + session_id: str | None, + target: str | None = None, + ) -> dict[str, Any]: + if action not in {"copy", "cut", "paste"}: + raise BrowserError("invalid_clipboard_action", action) + async with self._lock: + await self._ensure_agent_control(session_id) + info = await asyncio.to_thread(self.backend.target_info, target) + if self._sensitive(info): + self._require_user( + "login", "Clipboard access to authentication fields is user-only" + ) + return {**self.status.to_dict(), "user_action_required": True} + await asyncio.to_thread( + self.backend.clipboard_action, action, target=target + ) + await self._refresh() + return { + **self.status.to_dict(), + "clipboard_action": action, + "content_returned": False, + } + + async def upload_file( + self, + target: str, + path: str, + *, + session_id: str | None, + ) -> dict[str, Any]: + if self.workspace is None or session_id is None: + raise BrowserError( + "workspace_required", "Uploads require a Session workspace" + ) + async with self._lock: + await self._ensure_agent_control(session_id) + info = await asyncio.to_thread(self.backend.target_info, target) + if str(info.get("type") or "").lower() != "file": + raise BrowserError("not_file_input", "Target is not a file input") + try: + resolved = await asyncio.to_thread( + self.workspace.resolve_browser_upload, session_id, path + ) + except Exception as exc: + raise BrowserError("invalid_upload_path", str(exc)) from exc + await asyncio.to_thread(self.backend.upload_file, target, resolved) + await self._refresh() + return { + **self.status.to_dict(), + "uploaded": True, + "workspace_path": path, + "filename": resolved.name, + } + + async def collect_downloads( + self, + *, + session_id: str | None, + wait_ms: int = 0, + ) -> dict[str, Any]: + if self.workspace is None or session_id is None: + raise BrowserError( + "workspace_required", "Downloads require a Session workspace" + ) + async with self._lock: + await self._ensure_agent_control(session_id) + staged = await asyncio.to_thread( + self.backend.staged_downloads, wait_ms=wait_ms + ) + adopted = [] + for item in staged["complete"]: + try: + adopted.append( + await asyncio.to_thread( + self.workspace.adopt_browser_download, + session_id, + item["name"], + ) + ) + except Exception as exc: + raise BrowserError("download_adoption_failed", str(exc)) from exc + await self._refresh() + return { + **self.status.to_dict(), + "downloads": adopted, + "in_progress": staged["in_progress"], + } + + async def scroll(self, delta_y: int, *, session_id: str | None) -> dict[str, Any]: + async with self._lock: + await self._ensure_agent_control(session_id) + await asyncio.to_thread(self.backend.scroll, delta_y) + await self._refresh() + return {**self.status.to_dict(), "scrolled": delta_y} + + async def screenshot(self, *, session_id: str | None) -> dict[str, Any]: + async with self._lock: + await self._ensure_agent_control(session_id) + if await self._detect_challenge() is not None: + return {**self.status.to_dict(), "user_action_required": True} + image = await asyncio.to_thread(self.backend.screenshot) + await self._refresh() + return {**self.status.to_dict(), "format": "png", "base64": image} + + async def begin_user_control(self) -> dict[str, Any]: + async with self._lock: + if self.status.state not in { + BrowserControlState.USER_REQUIRED, + BrowserControlState.AGENT_CONTROL, + }: + raise BrowserError( + "invalid_browser_state", + f"Cannot enter user control from {self.status.state.value}", + ) + self.status.state = BrowserControlState.USER_CONTROL + self.status.recent_events = [] + return self.status.to_dict() + + async def complete_user_control(self) -> dict[str, Any]: + async with self._lock: + if self.status.state is not BrowserControlState.USER_CONTROL: + raise BrowserError( + "invalid_browser_state", "The browser is not under user control" + ) + challenge = await asyncio.to_thread(self.backend.detect_authentication) + if challenge is not None: + self.status.state = BrowserControlState.USER_REQUIRED + self.status.challenge = challenge + return {**self.status.to_dict(), "completed": False} + self.status.state = BrowserControlState.AGENT_CONTROL + self.status.challenge = None + await self._refresh() + return {**self.status.to_dict(), "completed": True} + + async def get_status(self) -> dict[str, Any]: + async with self._lock: + if self.status.state is BrowserControlState.AGENT_CONTROL: + await self._refresh() + return self.status.to_dict() + + async def _ensure_agent_control(self, session_id: str | None) -> None: + self._claim(session_id) + if self.status.state is BrowserControlState.STOPPED: + await self._prepare_session_io(session_id) + await asyncio.to_thread(self.backend.start) + self.status.state = BrowserControlState.AGENT_CONTROL + if self.status.state in { + BrowserControlState.USER_REQUIRED, + BrowserControlState.USER_CONTROL, + }: + raise BrowserError( + "user_control_active", + "Authentication must be completed by the user before Agent control resumes", + ) + + def _claim(self, session_id: str | None) -> None: + if self.status.owner_session_id is None: + self.status.owner_session_id = session_id + elif session_id is not None and self.status.owner_session_id != session_id: + raise BrowserError( + "browser_in_use", + "The managed browser belongs to another active Session", + ) + + async def _detect_challenge(self): + challenge = await asyncio.to_thread(self.backend.detect_authentication) + if challenge is not None: + self.status.challenge = challenge + self.status.state = BrowserControlState.USER_REQUIRED + self.status.recent_events = [] + if self.status.url: + parsed = urlsplit(self.status.url) + self.status.url = urlunsplit( + (parsed.scheme, parsed.netloc, parsed.path, "", "") + ) + self.status.title = "Authentication required" + return challenge + + def _require_user(self, kind: str, reason: str) -> None: + from .models import AuthenticationChallenge + + self.status.challenge = AuthenticationChallenge(kind, reason) + self.status.state = BrowserControlState.USER_REQUIRED + + async def _refresh(self) -> None: + if self.status.state in { + BrowserControlState.USER_REQUIRED, + BrowserControlState.USER_CONTROL, + }: + return + url, title = await asyncio.to_thread(self.backend.current) + self.status.url = url + self.status.title = title + self.status.bidi_connected = bool(self.backend.bidi_connected) + self.status.recent_events = self.backend.recent_events()[-20:] + + @staticmethod + def _sensitive(info: dict[str, Any]) -> bool: + return ( + info.get("type") == "password" + or info.get("autocomplete") in _SENSITIVE_AUTOCOMPLETE + ) + + @staticmethod + def _validate_url(url: str) -> None: + parsed = urlsplit(url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise BrowserError( + "unsafe_browser_url", "Managed browser navigation requires HTTP(S)" + ) + if parsed.username is not None or parsed.password is not None: + raise BrowserError( + "unsafe_browser_url", "Credentials are not allowed in browser URLs" + ) diff --git a/ai2apps/browser/models.py b/ai2apps/browser/models.py new file mode 100644 index 00000000..ab73eebc --- /dev/null +++ b/ai2apps/browser/models.py @@ -0,0 +1,101 @@ +"""Stable browser-runtime contracts and safety states.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any + + +class BrowserControlState(StrEnum): + STOPPED = "stopped" + AGENT_CONTROL = "agent_control" + USER_REQUIRED = "user_required" + USER_CONTROL = "user_control" + + +class BrowserError(RuntimeError): + def __init__(self, code: str, message: str) -> None: + self.code = code + super().__init__(message) + + +@dataclass(frozen=True, slots=True) +class AuthenticationChallenge: + kind: str + reason: str + + +@dataclass(frozen=True, slots=True) +class BrowserRuntimeConfig: + profile_path: str + binary_path: str | None = None + driver_path: str | None = None + headless: bool = False + page_load_timeout_seconds: float = 45.0 + + +@dataclass(frozen=True, slots=True) +class BrowserSnapshot: + url: str + title: str + items: tuple[dict[str, Any], ...] + text: str + html: str = "" + html_mode: str = "visible" + html_truncated: bool = False + + +@dataclass(frozen=True, slots=True) +class BrowserArticle: + url: str + canonical_url: str | None + title: str | None + byline: str | None + site_name: str | None + published_at: str | None + language: str | None + direction: str | None + excerpt: str | None + html: str + text: str + text_length: int + reading_time_minutes: int + extraction_method: str + confidence: str + truncated: bool = False + warnings: tuple[str, ...] = () + hidden_nodes_removed: int = 0 + + +@dataclass(slots=True) +class BrowserRuntimeStatus: + state: BrowserControlState = BrowserControlState.STOPPED + owner_session_id: str | None = None + url: str | None = None + title: str | None = None + challenge: AuthenticationChallenge | None = None + transport: str = "webdriver-bidi" + engine: str = "chromium" + bidi_connected: bool = False + recent_events: list[dict[str, Any]] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "state": self.state.value, + "owner_session_id": self.owner_session_id, + "url": self.url, + "title": self.title, + "challenge": ( + None + if self.challenge is None + else { + "kind": self.challenge.kind, + "reason": self.challenge.reason, + } + ), + "transport": self.transport, + "engine": self.engine, + "bidi_connected": self.bidi_connected, + "recent_events": self.recent_events[-20:], + } diff --git a/ai2apps/browser/readability.js b/ai2apps/browser/readability.js new file mode 100644 index 00000000..1a23c95f --- /dev/null +++ b/ai2apps/browser/readability.js @@ -0,0 +1,2786 @@ +/* + * Copyright (c) 2010 Arc90 Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * This code is heavily based on Arc90's readability.js (1.7.1) script + * available at: http://code.google.com/p/arc90labs-readability + */ + +/** + * Public constructor. + * @param {HTMLDocument} doc The document to parse. + * @param {Object} options The options object. + */ +function setReadablility() { + let Readability; + Readability=function(doc, options) { + // In some older versions, people passed a URI as the first argument. Cope: + if (options && options.documentElement) { + doc = options; + options = arguments[2]; + } else if (!doc || !doc.documentElement) { + throw new Error( + "First argument to Readability constructor should be a document object." + ); + } + options = options || {}; + + this._doc = doc; + this._docJSDOMParser = this._doc.firstChild.__JSDOMParser__; + this._articleTitle = null; + this._articleByline = null; + this._articleDir = null; + this._articleSiteName = null; + this._attempts = []; + this._metadata = {}; + + // Configurable options + this._debug = !!options.debug; + this._maxElemsToParse = + options.maxElemsToParse || this.DEFAULT_MAX_ELEMS_TO_PARSE; + this._nbTopCandidates = + options.nbTopCandidates || this.DEFAULT_N_TOP_CANDIDATES; + this._charThreshold = options.charThreshold || this.DEFAULT_CHAR_THRESHOLD; + this._classesToPreserve = this.CLASSES_TO_PRESERVE.concat( + options.classesToPreserve || [] + ); + this._keepClasses = !!options.keepClasses; + this._serializer = + options.serializer || + function (el) { + return el.innerHTML; + }; + this._disableJSONLD = !!options.disableJSONLD; + this._allowedVideoRegex = options.allowedVideoRegex || this.REGEXPS.videos; + this._linkDensityModifier = options.linkDensityModifier || 0; + + // Start with all flags set + this._flags = + this.FLAG_STRIP_UNLIKELYS | + this.FLAG_WEIGHT_CLASSES | + this.FLAG_CLEAN_CONDITIONALLY; + + // Control whether log messages are sent to the console + if (this._debug) { + let logNode = function (node) { + if (node.nodeType == node.TEXT_NODE) { + return `${node.nodeName} ("${node.textContent}")`; + } + let attrPairs = Array.from(node.attributes || [], function (attr) { + return `${attr.name}="${attr.value}"`; + }).join(" "); + return `<${node.localName} ${attrPairs}>`; + }; + this.log = function () { + if (typeof console !== "undefined") { + let args = Array.from(arguments, arg => { + if (arg && arg.nodeType == this.ELEMENT_NODE) { + return logNode(arg); + } + return arg; + }); + args.unshift("Reader: (Readability)"); + // eslint-disable-next-line no-console + console.log(...args); + } else if (typeof dump !== "undefined") { + /* global dump */ + var msg = Array.prototype.map + .call(arguments, function (x) { + return x && x.nodeName ? logNode(x) : x; + }) + .join(" "); + dump("Reader: (Readability) " + msg + "\n"); + } + }; + } else { + this.log = function () {}; + } + } + Readability.prototype = { + FLAG_STRIP_UNLIKELYS: 0x1, + FLAG_WEIGHT_CLASSES: 0x2, + FLAG_CLEAN_CONDITIONALLY: 0x4, + + // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType + ELEMENT_NODE: 1, + TEXT_NODE: 3, + + // Max number of nodes supported by this parser. Default: 0 (no limit) + DEFAULT_MAX_ELEMS_TO_PARSE: 0, + + // The number of top candidates to consider when analysing how + // tight the competition is among candidates. + DEFAULT_N_TOP_CANDIDATES: 5, + + // Element tags to score by default. + DEFAULT_TAGS_TO_SCORE: "section,h2,h3,h4,h5,h6,p,td,pre" + .toUpperCase() + .split(","), + + // The default number of chars an article must have in order to return a result + DEFAULT_CHAR_THRESHOLD: 500, + + // All of the regular expressions in use within readability. + // Defined up here so we don't instantiate them repeatedly in loops. + REGEXPS: { + // NOTE: These two regular expressions are duplicated in + // Readability-readerable.js. Please keep both copies in sync. + unlikelyCandidates: + /-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i, + okMaybeItsACandidate: /and|article|body|column|content|main|shadow/i, + + positive: + /article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story/i, + negative: + /-ad-|hidden|^hid$| hid$| hid |^hid |banner|combx|comment|com-|contact|footer|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|widget/i, + extraneous: + /print|archive|comment|discuss|e[\-]?mail|share|reply|all|login|sign|single|utility/i, + byline: /byline|author|dateline|writtenby|p-author/i, + replaceFonts: /<(\/?)font[^>]*>/gi, + normalize: /\s{2,}/g, + videos: + /\/\/(www\.)?((dailymotion|youtube|youtube-nocookie|player\.vimeo|v\.qq)\.com|(archive|upload\.wikimedia)\.org|player\.twitch\.tv)/i, + shareElements: /(\b|_)(share|sharedaddy)(\b|_)/i, + nextLink: /(next|weiter|continue|>([^\|]|$)|»([^\|]|$))/i, + prevLink: /(prev|earl|old|new|<|«)/i, + tokenize: /\W+/g, + whitespace: /^\s*$/, + hasContent: /\S$/, + hashUrl: /^#.+/, + srcsetUrl: /(\S+)(\s+[\d.]+[xw])?(\s*(?:,|$))/g, + b64DataUrl: /^data:\s*([^\s;,]+)\s*;\s*base64\s*,/i, + // Commas as used in Latin, Sindhi, Chinese and various other scripts. + // see: https://en.wikipedia.org/wiki/Comma#Comma_variants + commas: /\u002C|\u060C|\uFE50|\uFE10|\uFE11|\u2E41|\u2E34|\u2E32|\uFF0C/g, + // See: https://schema.org/Article + jsonLdArticleTypes: + /^Article|AdvertiserContentArticle|NewsArticle|AnalysisNewsArticle|AskPublicNewsArticle|BackgroundNewsArticle|OpinionNewsArticle|ReportageNewsArticle|ReviewNewsArticle|Report|SatiricalArticle|ScholarlyArticle|MedicalScholarlyArticle|SocialMediaPosting|BlogPosting|LiveBlogPosting|DiscussionForumPosting|TechArticle|APIReference$/, + // used to see if a node's content matches words commonly used for ad blocks or loading indicators + adWords: + /^(ad(vertising|vertisement)?|pub(licité)?|werb(ung)?|广告|Реклама|Anuncio)$/iu, + loadingWords: + /^((loading|正在加载|Загрузка|chargement|cargando)(…|\.\.\.)?)$/iu, + }, + + UNLIKELY_ROLES: [ + "menu", + "menubar", + "complementary", + "navigation", + "alert", + "alertdialog", + "dialog", + ], + + DIV_TO_P_ELEMS: new Set([ + "BLOCKQUOTE", + "DL", + "DIV", + "IMG", + "OL", + "P", + "PRE", + "TABLE", + "UL", + ]), + + ALTER_TO_DIV_EXCEPTIONS: ["DIV", "ARTICLE", "SECTION", "P", "OL", "UL"], + + PRESENTATIONAL_ATTRIBUTES: [ + "align", + "background", + "bgcolor", + "border", + "cellpadding", + "cellspacing", + "frame", + "hspace", + "rules", + "style", + "valign", + "vspace", + ], + + DEPRECATED_SIZE_ATTRIBUTE_ELEMS: ["TABLE", "TH", "TD", "HR", "PRE"], + + // The commented out elements qualify as phrasing content but tend to be + // removed by readability when put into paragraphs, so we ignore them here. + PHRASING_ELEMS: [ + // "CANVAS", "IFRAME", "SVG", "VIDEO", + "ABBR", + "AUDIO", + "B", + "BDO", + "BR", + "BUTTON", + "CITE", + "CODE", + "DATA", + "DATALIST", + "DFN", + "EM", + "EMBED", + "I", + "IMG", + "INPUT", + "KBD", + "LABEL", + "MARK", + "MATH", + "METER", + "NOSCRIPT", + "OBJECT", + "OUTPUT", + "PROGRESS", + "Q", + "RUBY", + "SAMP", + "SCRIPT", + "SELECT", + "SMALL", + "SPAN", + "STRONG", + "SUB", + "SUP", + "TEXTAREA", + "TIME", + "VAR", + "WBR", + ], + + // These are the classes that readability sets itself. + CLASSES_TO_PRESERVE: ["page"], + + // These are the list of HTML entities that need to be escaped. + HTML_ESCAPE_MAP: { + lt: "<", + gt: ">", + amp: "&", + quot: '"', + apos: "'", + }, + + /** + * Run any post-process modifications to article content as necessary. + * + * @param Element + * @return void + **/ + _postProcessContent (articleContent) { + // Readability cannot open relative uris so we convert them to absolute uris. + this._fixRelativeUris(articleContent); + + this._simplifyNestedElements(articleContent); + + if (!this._keepClasses) { + // Remove classes. + this._cleanClasses(articleContent); + } + }, + + /** + * Iterates over a NodeList, calls `filterFn` for each node and removes node + * if function returned `true`. + * + * If function is not passed, removes all the nodes in node list. + * + * @param NodeList nodeList The nodes to operate on + * @param Function filterFn the function to use as a filter + * @return void + */ + _removeNodes (nodeList, filterFn) { + // Avoid ever operating on live node lists. + if (this._docJSDOMParser && nodeList._isLiveNodeList) { + throw new Error("Do not pass live node lists to _removeNodes"); + } + for (var i = nodeList.length - 1; i >= 0; i--) { + var node = nodeList[i]; + var parentNode = node.parentNode; + if (parentNode) { + if (!filterFn || filterFn.call(this, node, i, nodeList)) { + parentNode.removeChild(node); + } + } + } + }, + + /** + * Iterates over a NodeList, and calls _setNodeTag for each node. + * + * @param NodeList nodeList The nodes to operate on + * @param String newTagName the new tag name to use + * @return void + */ + _replaceNodeTags (nodeList, newTagName) { + // Avoid ever operating on live node lists. + if (this._docJSDOMParser && nodeList._isLiveNodeList) { + throw new Error("Do not pass live node lists to _replaceNodeTags"); + } + for (const node of nodeList) { + this._setNodeTag(node, newTagName); + } + }, + + /** + * Iterate over a NodeList, which doesn't natively fully implement the Array + * interface. + * + * For convenience, the current object context is applied to the provided + * iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return void + */ + _forEachNode (nodeList, fn) { + Array.prototype.forEach.call(nodeList, fn, this); + }, + + /** + * Iterate over a NodeList, and return the first node that passes + * the supplied test function + * + * For convenience, the current object context is applied to the provided + * test function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The test function. + * @return void + */ + _findNode (nodeList, fn) { + return Array.prototype.find.call(nodeList, fn, this); + }, + + /** + * Iterate over a NodeList, return true if any of the provided iterate + * function calls returns true, false otherwise. + * + * For convenience, the current object context is applied to the + * provided iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return Boolean + */ + _someNode (nodeList, fn) { + return Array.prototype.some.call(nodeList, fn, this); + }, + + /** + * Iterate over a NodeList, return true if all of the provided iterate + * function calls return true, false otherwise. + * + * For convenience, the current object context is applied to the + * provided iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return Boolean + */ + _everyNode (nodeList, fn) { + return Array.prototype.every.call(nodeList, fn, this); + }, + + _getAllNodesWithTag (node, tagNames) { + if (node.querySelectorAll) { + return node.querySelectorAll(tagNames.join(",")); + } + return [].concat.apply( + [], + tagNames.map(function (tag) { + var collection = node.getElementsByTagName(tag); + return Array.isArray(collection) ? collection : Array.from(collection); + }) + ); + }, + + /** + * Removes the class="" attribute from every element in the given + * subtree, except those that match CLASSES_TO_PRESERVE and + * the classesToPreserve array from the options object. + * + * @param Element + * @return void + */ + _cleanClasses (node) { + var classesToPreserve = this._classesToPreserve; + var className = (node.getAttribute("class") || "") + .split(/\s+/) + .filter(cls => classesToPreserve.includes(cls)) + .join(" "); + + if (className) { + node.setAttribute("class", className); + } else { + node.removeAttribute("class"); + } + + for (node = node.firstElementChild; node; node = node.nextElementSibling) { + this._cleanClasses(node); + } + }, + + /** + * Tests whether a string is a URL or not. + * + * @param {string} str The string to test + * @return {boolean} true if str is a URL, false if not + */ + _isUrl (str) { + try { + new URL(str); + return true; + } catch { + return false; + } + }, + /** + * Converts each and uri in the given element to an absolute URI, + * ignoring #ref URIs. + * + * @param Element + * @return void + */ + _fixRelativeUris (articleContent) { + var baseURI = this._doc.baseURI; + var documentURI = this._doc.documentURI; + + function toAbsoluteURI (uri) { + // Leave hash links alone if the base URI matches the document URI: + if (baseURI == documentURI && uri.charAt(0) == "#") { + return uri; + } + + // Otherwise, resolve against base URI: + try { + return new URL(uri, baseURI).href; + } catch (ex) { + // Something went wrong, just return the original: + } + return uri; + } + + var links = this._getAllNodesWithTag(articleContent, ["a"]); + this._forEachNode(links, function (link) { + var href = link.getAttribute("href"); + if (href) { + // Remove links with javascript: URIs, since + // they won't work after scripts have been removed from the page. + if (href.indexOf("javascript:") === 0) { + // if the link only contains simple text content, it can be converted to a text node + if ( + link.childNodes.length === 1 && + link.childNodes[0].nodeType === this.TEXT_NODE + ) { + var text = this._doc.createTextNode(link.textContent); + link.parentNode.replaceChild(text, link); + } else { + // if the link has multiple children, they should all be preserved + var container = this._doc.createElement("span"); + while (link.firstChild) { + container.appendChild(link.firstChild); + } + link.parentNode.replaceChild(container, link); + } + } else { + link.setAttribute("href", toAbsoluteURI(href)); + } + } + }); + + var medias = this._getAllNodesWithTag(articleContent, [ + "img", + "picture", + "figure", + "video", + "audio", + "source", + ]); + + this._forEachNode(medias, function (media) { + var src = media.getAttribute("src"); + var poster = media.getAttribute("poster"); + var srcset = media.getAttribute("srcset"); + + if (src) { + media.setAttribute("src", toAbsoluteURI(src)); + } + + if (poster) { + media.setAttribute("poster", toAbsoluteURI(poster)); + } + + if (srcset) { + var newSrcset = srcset.replace( + this.REGEXPS.srcsetUrl, + function (_, p1, p2, p3) { + return toAbsoluteURI(p1) + (p2 || "") + p3; + } + ); + + media.setAttribute("srcset", newSrcset); + } + }); + }, + + _simplifyNestedElements (articleContent) { + var node = articleContent; + + while (node) { + if ( + node.parentNode && + ["DIV", "SECTION"].includes(node.tagName) && + !(node.id && node.id.startsWith("readability")) + ) { + if (this._isElementWithoutContent(node)) { + node = this._removeAndGetNext(node); + continue; + } else if ( + this._hasSingleTagInsideElement(node, "DIV") || + this._hasSingleTagInsideElement(node, "SECTION") + ) { + var child = node.children[0]; + for (var i = 0; i < node.attributes.length; i++) { + child.setAttributeNode(node.attributes[i].cloneNode()); + } + node.parentNode.replaceChild(child, node); + node = child; + continue; + } + } + + node = this._getNextNode(node); + } + }, + + /** + * Get the article title as an H1. + * + * @return string + **/ + _getArticleTitle () { + var doc = this._doc; + var curTitle = ""; + var origTitle = ""; + + try { + curTitle = origTitle = doc.title.trim(); + + // If they had an element with id "title" in their HTML + if (typeof curTitle !== "string") { + curTitle = origTitle = this._getInnerText( + doc.getElementsByTagName("title")[0] + ); + } + } catch (e) { + /* ignore exceptions setting the title. */ + } + + var titleHadHierarchicalSeparators = false; + + function wordCount (str) { + return str.split(/\s+/).length; + } + + // If there's a separator in the title, first remove the final part + if (/ [\|\-\\\/>»] /.test(curTitle)) { + titleHadHierarchicalSeparators = / [\\\/>»] /.test(curTitle); + let allSeparators = Array.from(origTitle.matchAll(/ [\|\-\\\/>»] /gi)); + curTitle = origTitle.substring(0, allSeparators.pop().index); + + // If the resulting title is too short, remove the first part instead: + if (wordCount(curTitle) < 3) { + curTitle = origTitle.replace(/^[^\|\-\\\/>»]*[\|\-\\\/>»]/gi, ""); + } + } else if (curTitle.includes(": ")) { + // Check if we have an heading containing this exact string, so we + // could assume it's the full title. + var headings = this._getAllNodesWithTag(doc, ["h1", "h2"]); + var trimmedTitle = curTitle.trim(); + var match = this._someNode(headings, function (heading) { + return heading.textContent.trim() === trimmedTitle; + }); + + // If we don't, let's extract the title out of the original title string. + if (!match) { + curTitle = origTitle.substring(origTitle.lastIndexOf(":") + 1); + + // If the title is now too short, try the first colon instead: + if (wordCount(curTitle) < 3) { + curTitle = origTitle.substring(origTitle.indexOf(":") + 1); + // But if we have too many words before the colon there's something weird + // with the titles and the H tags so let's just use the original title instead + } else if (wordCount(origTitle.substr(0, origTitle.indexOf(":"))) > 5) { + curTitle = origTitle; + } + } + } else if (curTitle.length > 150 || curTitle.length < 15) { + var hOnes = doc.getElementsByTagName("h1"); + + if (hOnes.length === 1) { + curTitle = this._getInnerText(hOnes[0]); + } + } + + curTitle = curTitle.trim().replace(this.REGEXPS.normalize, " "); + // If we now have 4 words or fewer as our title, and either no + // 'hierarchical' separators (\, /, > or ») were found in the original + // title or we decreased the number of words by more than 1 word, use + // the original title. + var curTitleWordCount = wordCount(curTitle); + if ( + curTitleWordCount <= 4 && + (!titleHadHierarchicalSeparators || + curTitleWordCount != + wordCount(origTitle.replace(/[\|\-\\\/>»]+/g, "")) - 1) + ) { + curTitle = origTitle; + } + + return curTitle; + }, + + /** + * Prepare the HTML document for readability to scrape it. + * This includes things like stripping javascript, CSS, and handling terrible markup. + * + * @return void + **/ + _prepDocument () { + var doc = this._doc; + + // Remove all style tags in head + this._removeNodes(this._getAllNodesWithTag(doc, ["style"])); + + if (doc.body) { + this._replaceBrs(doc.body); + } + + this._replaceNodeTags(this._getAllNodesWithTag(doc, ["font"]), "SPAN"); + }, + + /** + * Finds the next node, starting from the given node, and ignoring + * whitespace in between. If the given node is an element, the same node is + * returned. + */ + _nextNode (node) { + var next = node; + while ( + next && + next.nodeType != this.ELEMENT_NODE && + this.REGEXPS.whitespace.test(next.textContent) + ) { + next = next.nextSibling; + } + return next; + }, + + /** + * Replaces 2 or more successive
elements with a single

. + * Whitespace between
elements are ignored. For example: + *

foo
bar


abc
+ * will become: + *
foo
bar

abc

+ */ + _replaceBrs (elem) { + this._forEachNode(this._getAllNodesWithTag(elem, ["br"]), function (br) { + var next = br.nextSibling; + + // Whether 2 or more
elements have been found and replaced with a + //

block. + var replaced = false; + + // If we find a
chain, remove the
s until we hit another node + // or non-whitespace. This leaves behind the first
in the chain + // (which will be replaced with a

later). + while ((next = this._nextNode(next)) && next.tagName == "BR") { + replaced = true; + var brSibling = next.nextSibling; + next.remove(); + next = brSibling; + } + + // If we removed a
chain, replace the remaining
with a

. Add + // all sibling nodes as children of the

until we hit another
+ // chain. + if (replaced) { + var p = this._doc.createElement("p"); + br.parentNode.replaceChild(p, br); + + next = p.nextSibling; + while (next) { + // If we've hit another

, we're done adding children to this

. + if (next.tagName == "BR") { + var nextElem = this._nextNode(next.nextSibling); + if (nextElem && nextElem.tagName == "BR") { + break; + } + } + + if (!this._isPhrasingContent(next)) { + break; + } + + // Otherwise, make this node a child of the new

. + var sibling = next.nextSibling; + p.appendChild(next); + next = sibling; + } + + while (p.lastChild && this._isWhitespace(p.lastChild)) { + p.lastChild.remove(); + } + + if (p.parentNode.tagName === "P") { + this._setNodeTag(p.parentNode, "DIV"); + } + } + }); + }, + + _setNodeTag (node, tag) { + this.log("_setNodeTag", node, tag); + if (this._docJSDOMParser) { + node.localName = tag.toLowerCase(); + node.tagName = tag.toUpperCase(); + return node; + } + + var replacement = node.ownerDocument.createElement(tag); + while (node.firstChild) { + replacement.appendChild(node.firstChild); + } + node.parentNode.replaceChild(replacement, node); + if (node.readability) { + replacement.readability = node.readability; + } + + for (var i = 0; i < node.attributes.length; i++) { + replacement.setAttributeNode(node.attributes[i].cloneNode()); + } + return replacement; + }, + + /** + * Prepare the article node for display. Clean out any inline styles, + * iframes, forms, strip extraneous

tags, etc. + * + * @param Element + * @return void + **/ + _prepArticle (articleContent) { + this._cleanStyles(articleContent); + + // Check for data tables before we continue, to avoid removing items in + // those tables, which will often be isolated even though they're + // visually linked to other content-ful elements (text, images, etc.). + this._markDataTables(articleContent); + + this._fixLazyImages(articleContent); + + // Clean out junk from the article content + this._cleanConditionally(articleContent, "form"); + this._cleanConditionally(articleContent, "fieldset"); + this._clean(articleContent, "object"); + this._clean(articleContent, "embed"); + this._clean(articleContent, "footer"); + this._clean(articleContent, "link"); + this._clean(articleContent, "aside"); + + // Clean out elements with little content that have "share" in their id/class combinations from final top candidates, + // which means we don't remove the top candidates even they have "share". + + var shareElementThreshold = this.DEFAULT_CHAR_THRESHOLD; + + this._forEachNode(articleContent.children, function (topCandidate) { + this._cleanMatchedNodes(topCandidate, function (node, matchString) { + return ( + this.REGEXPS.shareElements.test(matchString) && + node.textContent.length < shareElementThreshold + ); + }); + }); + + this._clean(articleContent, "iframe"); + this._clean(articleContent, "input"); + this._clean(articleContent, "textarea"); + this._clean(articleContent, "select"); + this._clean(articleContent, "button"); + this._cleanHeaders(articleContent); + + // Do these last as the previous stuff may have removed junk + // that will affect these + this._cleanConditionally(articleContent, "table"); + this._cleanConditionally(articleContent, "ul"); + this._cleanConditionally(articleContent, "div"); + + // replace H1 with H2 as H1 should be only title that is displayed separately + this._replaceNodeTags( + this._getAllNodesWithTag(articleContent, ["h1"]), + "h2" + ); + + // Remove extra paragraphs + this._removeNodes( + this._getAllNodesWithTag(articleContent, ["p"]), + function (paragraph) { + // At this point, nasty iframes have been removed; only embedded video + // ones remain. + var contentElementCount = this._getAllNodesWithTag(paragraph, [ + "img", + "embed", + "object", + "iframe", + ]).length; + return ( + contentElementCount === 0 && !this._getInnerText(paragraph, false) + ); + } + ); + + this._forEachNode( + this._getAllNodesWithTag(articleContent, ["br"]), + function (br) { + var next = this._nextNode(br.nextSibling); + if (next && next.tagName == "P") { + br.remove(); + } + } + ); + + // Remove single-cell tables + this._forEachNode( + this._getAllNodesWithTag(articleContent, ["table"]), + function (table) { + var tbody = this._hasSingleTagInsideElement(table, "TBODY") + ? table.firstElementChild + : table; + if (this._hasSingleTagInsideElement(tbody, "TR")) { + var row = tbody.firstElementChild; + if (this._hasSingleTagInsideElement(row, "TD")) { + var cell = row.firstElementChild; + cell = this._setNodeTag( + cell, + this._everyNode(cell.childNodes, this._isPhrasingContent) + ? "P" + : "DIV" + ); + table.parentNode.replaceChild(cell, table); + } + } + } + ); + }, + + /** + * Initialize a node with the readability object. Also checks the + * className/id for special names to add to its score. + * + * @param Element + * @return void + **/ + _initializeNode (node) { + node.readability = { contentScore: 0 }; + + switch (node.tagName) { + case "DIV": + node.readability.contentScore += 5; + break; + + case "PRE": + case "TD": + case "BLOCKQUOTE": + node.readability.contentScore += 3; + break; + + case "ADDRESS": + case "OL": + case "UL": + case "DL": + case "DD": + case "DT": + case "LI": + case "FORM": + node.readability.contentScore -= 3; + break; + + case "H1": + case "H2": + case "H3": + case "H4": + case "H5": + case "H6": + case "TH": + node.readability.contentScore -= 5; + break; + } + + node.readability.contentScore += this._getClassWeight(node); + }, + + _removeAndGetNext (node) { + var nextNode = this._getNextNode(node, true); + node.remove(); + return nextNode; + }, + + /** + * Traverse the DOM from node to node, starting at the node passed in. + * Pass true for the second parameter to indicate this node itself + * (and its kids) are going away, and we want the next node over. + * + * Calling this in a loop will traverse the DOM depth-first. + * + * @param {Element} node + * @param {boolean} ignoreSelfAndKids + * @return {Element} + */ + _getNextNode (node, ignoreSelfAndKids) { + // First check for kids if those aren't being ignored + if (!ignoreSelfAndKids && node.firstElementChild) { + return node.firstElementChild; + } + // Then for siblings... + if (node.nextElementSibling) { + return node.nextElementSibling; + } + // And finally, move up the parent chain *and* find a sibling + // (because this is depth-first traversal, we will have already + // seen the parent nodes themselves). + do { + node = node.parentNode; + } while (node && !node.nextElementSibling); + return node && node.nextElementSibling; + }, + + // compares second text to first one + // 1 = same text, 0 = completely different text + // works the way that it splits both texts into words and then finds words that are unique in second text + // the result is given by the lower length of unique parts + _textSimilarity (textA, textB) { + var tokensA = textA + .toLowerCase() + .split(this.REGEXPS.tokenize) + .filter(Boolean); + var tokensB = textB + .toLowerCase() + .split(this.REGEXPS.tokenize) + .filter(Boolean); + if (!tokensA.length || !tokensB.length) { + return 0; + } + var uniqTokensB = tokensB.filter(token => !tokensA.includes(token)); + var distanceB = uniqTokensB.join(" ").length / tokensB.join(" ").length; + return 1 - distanceB; + }, + + /** + * Checks whether an element node contains a valid byline + * + * @param node {Element} + * @param matchString {string} + * @return boolean + */ + _isValidByline (node, matchString) { + var rel = node.getAttribute("rel"); + var itemprop = node.getAttribute("itemprop"); + var bylineLength = node.textContent.trim().length; + + return ( + (rel === "author" || + (itemprop && itemprop.includes("author")) || + this.REGEXPS.byline.test(matchString)) && + !!bylineLength && + bylineLength < 100 + ); + }, + + _getNodeAncestors (node, maxDepth) { + maxDepth = maxDepth || 0; + var i = 0, + ancestors = []; + while (node.parentNode) { + ancestors.push(node.parentNode); + if (maxDepth && ++i === maxDepth) { + break; + } + node = node.parentNode; + } + return ancestors; + }, + + /*** + * grabArticle - Using a variety of metrics (content score, classname, element types), find the content that is + * most likely to be the stuff a user wants to read. Then return it wrapped up in a div. + * + * @param page a document to run upon. Needs to be a full document, complete with body. + * @return Element + **/ + /* eslint-disable-next-line complexity */ + _grabArticle (page) { + this.log("**** grabArticle ****"); + var doc = this._doc; + var isPaging = page !== null; + page = page ? page : this._doc.body; + + // We can't grab an article if we don't have a page! + if (!page) { + this.log("No body found in document. Abort."); + return null; + } + + var pageCacheHtml = page.innerHTML; + + while (true) { + this.log("Starting grabArticle loop"); + var stripUnlikelyCandidates = this._flagIsActive( + this.FLAG_STRIP_UNLIKELYS + ); + + // First, node prepping. Trash nodes that look cruddy (like ones with the + // class name "comment", etc), and turn divs into P tags where they have been + // used inappropriately (as in, where they contain no other block level elements.) + var elementsToScore = []; + var node = this._doc.documentElement; + + let shouldRemoveTitleHeader = true; + + while (node) { + if (node.tagName === "HTML") { + this._articleLang = node.getAttribute("lang"); + } + + var matchString = node.className + " " + node.id; + + if (!this._isProbablyVisible(node)) { + this.log("Removing hidden node - " + matchString); + node = this._removeAndGetNext(node); + continue; + } + + // User is not able to see elements applied with both "aria-modal = true" and "role = dialog" + if ( + node.getAttribute("aria-modal") == "true" && + node.getAttribute("role") == "dialog" + ) { + node = this._removeAndGetNext(node); + continue; + } + + // If we don't have a byline yet check to see if this node is a byline; if it is store the byline and remove the node. + if ( + !this._articleByline && + !this._metadata.byline && + this._isValidByline(node, matchString) + ) { + // Find child node matching [itemprop="name"] and use that if it exists for a more accurate author name byline + var endOfSearchMarkerNode = this._getNextNode(node, true); + var next = this._getNextNode(node); + var itemPropNameNode = null; + while (next && next != endOfSearchMarkerNode) { + var itemprop = next.getAttribute("itemprop"); + if (itemprop && itemprop.includes("name")) { + itemPropNameNode = next; + break; + } else { + next = this._getNextNode(next); + } + } + this._articleByline = (itemPropNameNode ?? node).textContent.trim(); + node = this._removeAndGetNext(node); + continue; + } + + if (shouldRemoveTitleHeader && this._headerDuplicatesTitle(node)) { + this.log( + "Removing header: ", + node.textContent.trim(), + this._articleTitle.trim() + ); + shouldRemoveTitleHeader = false; + node = this._removeAndGetNext(node); + continue; + } + + // Remove unlikely candidates + if (stripUnlikelyCandidates) { + if ( + this.REGEXPS.unlikelyCandidates.test(matchString) && + !this.REGEXPS.okMaybeItsACandidate.test(matchString) && + !this._hasAncestorTag(node, "table") && + !this._hasAncestorTag(node, "code") && + node.tagName !== "BODY" && + node.tagName !== "A" + ) { + this.log("Removing unlikely candidate - " + matchString); + node = this._removeAndGetNext(node); + continue; + } + + if (this.UNLIKELY_ROLES.includes(node.getAttribute("role"))) { + this.log( + "Removing content with role " + + node.getAttribute("role") + + " - " + + matchString + ); + node = this._removeAndGetNext(node); + continue; + } + } + + // Remove DIV, SECTION, and HEADER nodes without any content(e.g. text, image, video, or iframe). + if ( + (node.tagName === "DIV" || + node.tagName === "SECTION" || + node.tagName === "HEADER" || + node.tagName === "H1" || + node.tagName === "H2" || + node.tagName === "H3" || + node.tagName === "H4" || + node.tagName === "H5" || + node.tagName === "H6") && + this._isElementWithoutContent(node) + ) { + node = this._removeAndGetNext(node); + continue; + } + + if (this.DEFAULT_TAGS_TO_SCORE.includes(node.tagName)) { + elementsToScore.push(node); + } + + // Turn all divs that don't have children block level elements into p's + if (node.tagName === "DIV") { + // Put phrasing content into paragraphs. + var p = null; + var childNode = node.firstChild; + while (childNode) { + var nextSibling = childNode.nextSibling; + if (this._isPhrasingContent(childNode)) { + if (p !== null) { + p.appendChild(childNode); + } else if (!this._isWhitespace(childNode)) { + p = doc.createElement("p"); + node.replaceChild(p, childNode); + p.appendChild(childNode); + } + } else if (p !== null) { + while (p.lastChild && this._isWhitespace(p.lastChild)) { + p.lastChild.remove(); + } + p = null; + } + childNode = nextSibling; + } + + // Sites like http://mobile.slate.com encloses each paragraph with a DIV + // element. DIVs with only a P element inside and no text content can be + // safely converted into plain P elements to avoid confusing the scoring + // algorithm with DIVs with are, in practice, paragraphs. + if ( + this._hasSingleTagInsideElement(node, "P") && + this._getLinkDensity(node) < 0.25 + ) { + var newNode = node.children[0]; + node.parentNode.replaceChild(newNode, node); + node = newNode; + elementsToScore.push(node); + } else if (!this._hasChildBlockElement(node)) { + node = this._setNodeTag(node, "P"); + elementsToScore.push(node); + } + } + node = this._getNextNode(node); + } + + /** + * Loop through all paragraphs, and assign a score to them based on how content-y they look. + * Then add their score to their parent node. + * + * A score is determined by things like number of commas, class names, etc. Maybe eventually link density. + **/ + var candidates = []; + this._forEachNode(elementsToScore, function (elementToScore) { + if ( + !elementToScore.parentNode || + typeof elementToScore.parentNode.tagName === "undefined" + ) { + return; + } + + // If this paragraph is less than 25 characters, don't even count it. + var innerText = this._getInnerText(elementToScore); + if (innerText.length < 25) { + return; + } + + // Exclude nodes with no ancestor. + var ancestors = this._getNodeAncestors(elementToScore, 5); + if (ancestors.length === 0) { + return; + } + + var contentScore = 0; + + // Add a point for the paragraph itself as a base. + contentScore += 1; + + // Add points for any commas within this paragraph. + contentScore += innerText.split(this.REGEXPS.commas).length; + + // For every 100 characters in this paragraph, add another point. Up to 3 points. + contentScore += Math.min(Math.floor(innerText.length / 100), 3); + + // Initialize and score ancestors. + this._forEachNode(ancestors, function (ancestor, level) { + if ( + !ancestor.tagName || + !ancestor.parentNode || + typeof ancestor.parentNode.tagName === "undefined" + ) { + return; + } + + if (typeof ancestor.readability === "undefined") { + this._initializeNode(ancestor); + candidates.push(ancestor); + } + + // Node score divider: + // - parent: 1 (no division) + // - grandparent: 2 + // - great grandparent+: ancestor level * 3 + if (level === 0) { + var scoreDivider = 1; + } else if (level === 1) { + scoreDivider = 2; + } else { + scoreDivider = level * 3; + } + ancestor.readability.contentScore += contentScore / scoreDivider; + }); + }); + + // After we've calculated scores, loop through all of the possible + // candidate nodes we found and find the one with the highest score. + var topCandidates = []; + for (var c = 0, cl = candidates.length; c < cl; c += 1) { + var candidate = candidates[c]; + + // Scale the final candidates score based on link density. Good content + // should have a relatively small link density (5% or less) and be mostly + // unaffected by this operation. + var candidateScore = + candidate.readability.contentScore * + (1 - this._getLinkDensity(candidate)); + candidate.readability.contentScore = candidateScore; + + this.log("Candidate:", candidate, "with score " + candidateScore); + + for (var t = 0; t < this._nbTopCandidates; t++) { + var aTopCandidate = topCandidates[t]; + + if ( + !aTopCandidate || + candidateScore > aTopCandidate.readability.contentScore + ) { + topCandidates.splice(t, 0, candidate); + if (topCandidates.length > this._nbTopCandidates) { + topCandidates.pop(); + } + break; + } + } + } + + var topCandidate = topCandidates[0] || null; + var neededToCreateTopCandidate = false; + var parentOfTopCandidate; + + // If we still have no top candidate, just use the body as a last resort. + // We also have to copy the body node so it is something we can modify. + if (topCandidate === null || topCandidate.tagName === "BODY") { + // Move all of the page's children into topCandidate + topCandidate = doc.createElement("DIV"); + neededToCreateTopCandidate = true; + // Move everything (not just elements, also text nodes etc.) into the container + // so we even include text directly in the body: + while (page.firstChild) { + this.log("Moving child out:", page.firstChild); + topCandidate.appendChild(page.firstChild); + } + + page.appendChild(topCandidate); + + this._initializeNode(topCandidate); + } else if (topCandidate) { + // Find a better top candidate node if it contains (at least three) nodes which belong to `topCandidates` array + // and whose scores are quite closed with current `topCandidate` node. + var alternativeCandidateAncestors = []; + for (var i = 1; i < topCandidates.length; i++) { + if ( + topCandidates[i].readability.contentScore / + topCandidate.readability.contentScore >= + 0.75 + ) { + alternativeCandidateAncestors.push( + this._getNodeAncestors(topCandidates[i]) + ); + } + } + var MINIMUM_TOPCANDIDATES = 3; + if (alternativeCandidateAncestors.length >= MINIMUM_TOPCANDIDATES) { + parentOfTopCandidate = topCandidate.parentNode; + while (parentOfTopCandidate.tagName !== "BODY") { + var listsContainingThisAncestor = 0; + for ( + var ancestorIndex = 0; + ancestorIndex < alternativeCandidateAncestors.length && + listsContainingThisAncestor < MINIMUM_TOPCANDIDATES; + ancestorIndex++ + ) { + listsContainingThisAncestor += Number( + alternativeCandidateAncestors[ancestorIndex].includes( + parentOfTopCandidate + ) + ); + } + if (listsContainingThisAncestor >= MINIMUM_TOPCANDIDATES) { + topCandidate = parentOfTopCandidate; + break; + } + parentOfTopCandidate = parentOfTopCandidate.parentNode; + } + } + if (!topCandidate.readability) { + this._initializeNode(topCandidate); + } + + // Because of our bonus system, parents of candidates might have scores + // themselves. They get half of the node. There won't be nodes with higher + // scores than our topCandidate, but if we see the score going *up* in the first + // few steps up the tree, that's a decent sign that there might be more content + // lurking in other places that we want to unify in. The sibling stuff + // below does some of that - but only if we've looked high enough up the DOM + // tree. + parentOfTopCandidate = topCandidate.parentNode; + var lastScore = topCandidate.readability.contentScore; + // The scores shouldn't get too low. + var scoreThreshold = lastScore / 3; + while (parentOfTopCandidate.tagName !== "BODY") { + if (!parentOfTopCandidate.readability) { + parentOfTopCandidate = parentOfTopCandidate.parentNode; + continue; + } + var parentScore = parentOfTopCandidate.readability.contentScore; + if (parentScore < scoreThreshold) { + break; + } + if (parentScore > lastScore) { + // Alright! We found a better parent to use. + topCandidate = parentOfTopCandidate; + break; + } + lastScore = parentOfTopCandidate.readability.contentScore; + parentOfTopCandidate = parentOfTopCandidate.parentNode; + } + + // If the top candidate is the only child, use parent instead. This will help sibling + // joining logic when adjacent content is actually located in parent's sibling node. + parentOfTopCandidate = topCandidate.parentNode; + while ( + parentOfTopCandidate.tagName != "BODY" && + parentOfTopCandidate.children.length == 1 + ) { + topCandidate = parentOfTopCandidate; + parentOfTopCandidate = topCandidate.parentNode; + } + if (!topCandidate.readability) { + this._initializeNode(topCandidate); + } + } + + // Now that we have the top candidate, look through its siblings for content + // that might also be related. Things like preambles, content split by ads + // that we removed, etc. + var articleContent = doc.createElement("DIV"); + if (isPaging) { + articleContent.id = "readability-content"; + } + + var siblingScoreThreshold = Math.max( + 10, + topCandidate.readability.contentScore * 0.2 + ); + // Keep potential top candidate's parent node to try to get text direction of it later. + parentOfTopCandidate = topCandidate.parentNode; + var siblings = parentOfTopCandidate.children; + + for (var s = 0, sl = siblings.length; s < sl; s++) { + var sibling = siblings[s]; + var append = false; + + this.log( + "Looking at sibling node:", + sibling, + sibling.readability + ? "with score " + sibling.readability.contentScore + : "" + ); + this.log( + "Sibling has score", + sibling.readability ? sibling.readability.contentScore : "Unknown" + ); + + if (sibling === topCandidate) { + append = true; + } else { + var contentBonus = 0; + + // Give a bonus if sibling nodes and top candidates have the example same classname + if ( + sibling.className === topCandidate.className && + topCandidate.className !== "" + ) { + contentBonus += topCandidate.readability.contentScore * 0.2; + } + + if ( + sibling.readability && + sibling.readability.contentScore + contentBonus >= + siblingScoreThreshold + ) { + append = true; + } else if (sibling.nodeName === "P") { + var linkDensity = this._getLinkDensity(sibling); + var nodeContent = this._getInnerText(sibling); + var nodeLength = nodeContent.length; + + if (nodeLength > 80 && linkDensity < 0.25) { + append = true; + } else if ( + nodeLength < 80 && + nodeLength > 0 && + linkDensity === 0 && + nodeContent.search(/\.( |$)/) !== -1 + ) { + append = true; + } + } + } + + if (append) { + this.log("Appending node:", sibling); + + if (!this.ALTER_TO_DIV_EXCEPTIONS.includes(sibling.nodeName)) { + // We have a node that isn't a common block level element, like a form or td tag. + // Turn it into a div so it doesn't get filtered out later by accident. + this.log("Altering sibling:", sibling, "to div."); + + sibling = this._setNodeTag(sibling, "DIV"); + } + + articleContent.appendChild(sibling); + // Fetch children again to make it compatible + // with DOM parsers without live collection support. + siblings = parentOfTopCandidate.children; + // siblings is a reference to the children array, and + // sibling is removed from the array when we call appendChild(). + // As a result, we must revisit this index since the nodes + // have been shifted. + s -= 1; + sl -= 1; + } + } + + if (this._debug) { + this.log("Article content pre-prep: " + articleContent.innerHTML); + } + // So we have all of the content that we need. Now we clean it up for presentation. + this._prepArticle(articleContent); + if (this._debug) { + this.log("Article content post-prep: " + articleContent.innerHTML); + } + + if (neededToCreateTopCandidate) { + // We already created a fake div thing, and there wouldn't have been any siblings left + // for the previous loop, so there's no point trying to create a new div, and then + // move all the children over. Just assign IDs and class names here. No need to append + // because that already happened anyway. + topCandidate.id = "readability-page-1"; + topCandidate.className = "page"; + } else { + var div = doc.createElement("DIV"); + div.id = "readability-page-1"; + div.className = "page"; + while (articleContent.firstChild) { + div.appendChild(articleContent.firstChild); + } + articleContent.appendChild(div); + } + + if (this._debug) { + this.log("Article content after paging: " + articleContent.innerHTML); + } + + var parseSuccessful = true; + + // Now that we've gone through the full algorithm, check to see if + // we got any meaningful content. If we didn't, we may need to re-run + // grabArticle with different flags set. This gives us a higher likelihood of + // finding the content, and the sieve approach gives us a higher likelihood of + // finding the -right- content. + var textLength = this._getInnerText(articleContent, true).length; + if (textLength < this._charThreshold) { + parseSuccessful = false; + // eslint-disable-next-line no-unsanitized/property + page.innerHTML = pageCacheHtml; + + this._attempts.push({ + articleContent, + textLength, + }); + + if (this._flagIsActive(this.FLAG_STRIP_UNLIKELYS)) { + this._removeFlag(this.FLAG_STRIP_UNLIKELYS); + } else if (this._flagIsActive(this.FLAG_WEIGHT_CLASSES)) { + this._removeFlag(this.FLAG_WEIGHT_CLASSES); + } else if (this._flagIsActive(this.FLAG_CLEAN_CONDITIONALLY)) { + this._removeFlag(this.FLAG_CLEAN_CONDITIONALLY); + } else { + // No luck after removing flags, just return the longest text we found during the different loops + this._attempts.sort(function (a, b) { + return b.textLength - a.textLength; + }); + + // But first check if we actually have something + if (!this._attempts[0].textLength) { + return null; + } + + articleContent = this._attempts[0].articleContent; + parseSuccessful = true; + } + } + + if (parseSuccessful) { + // Find out text direction from ancestors of final top candidate. + var ancestors = [parentOfTopCandidate, topCandidate].concat( + this._getNodeAncestors(parentOfTopCandidate) + ); + this._someNode(ancestors, function (ancestor) { + if (!ancestor.tagName) { + return false; + } + var articleDir = ancestor.getAttribute("dir"); + if (articleDir) { + this._articleDir = articleDir; + return true; + } + return false; + }); + return articleContent; + } + } + }, + + /** + * Converts some of the common HTML entities in string to their corresponding characters. + * + * @param str {string} - a string to unescape. + * @return string without HTML entity. + */ + _unescapeHtmlEntities (str) { + if (!str) { + return str; + } + + var htmlEscapeMap = this.HTML_ESCAPE_MAP; + return str + .replace(/&(quot|amp|apos|lt|gt);/g, function (_, tag) { + return htmlEscapeMap[tag]; + }) + .replace(/&#(?:x([0-9a-f]+)|([0-9]+));/gi, function (_, hex, numStr) { + var num = parseInt(hex || numStr, hex ? 16 : 10); + + // these character references are replaced by a conforming HTML parser + if (num == 0 || num > 0x10ffff || (num >= 0xd800 && num <= 0xdfff)) { + num = 0xfffd; + } + + return String.fromCodePoint(num); + }); + }, + + /** + * Try to extract metadata from JSON-LD object. + * For now, only Schema.org objects of type Article or its subtypes are supported. + * @return Object with any metadata that could be extracted (possibly none) + */ + _getJSONLD (doc) { + var scripts = this._getAllNodesWithTag(doc, ["script"]); + + var metadata; + + this._forEachNode(scripts, function (jsonLdElement) { + if ( + !metadata && + jsonLdElement.getAttribute("type") === "application/ld+json" + ) { + try { + // Strip CDATA markers if present + var content = jsonLdElement.textContent.replace( + /^\s*\s*$/g, + "" + ); + var parsed = JSON.parse(content); + + if (Array.isArray(parsed)) { + parsed = parsed.find(it => { + return ( + it["@type"] && + it["@type"].match(this.REGEXPS.jsonLdArticleTypes) + ); + }); + if (!parsed) { + return; + } + } + + var schemaDotOrgRegex = /^https?\:\/\/schema\.org\/?$/; + var matches = + (typeof parsed["@context"] === "string" && + parsed["@context"].match(schemaDotOrgRegex)) || + (typeof parsed["@context"] === "object" && + typeof parsed["@context"]["@vocab"] == "string" && + parsed["@context"]["@vocab"].match(schemaDotOrgRegex)); + + if (!matches) { + return; + } + + if (!parsed["@type"] && Array.isArray(parsed["@graph"])) { + parsed = parsed["@graph"].find(it => { + return (it["@type"] || "").match(this.REGEXPS.jsonLdArticleTypes); + }); + } + + if ( + !parsed || + !parsed["@type"] || + !parsed["@type"].match(this.REGEXPS.jsonLdArticleTypes) + ) { + return; + } + + metadata = {}; + + if ( + typeof parsed.name === "string" && + typeof parsed.headline === "string" && + parsed.name !== parsed.headline + ) { + // we have both name and headline element in the JSON-LD. They should both be the same but some websites like aktualne.cz + // put their own name into "name" and the article title to "headline" which confuses Readability. So we try to check if either + // "name" or "headline" closely matches the html title, and if so, use that one. If not, then we use "name" by default. + + var title = this._getArticleTitle(); + var nameMatches = this._textSimilarity(parsed.name, title) > 0.75; + var headlineMatches = + this._textSimilarity(parsed.headline, title) > 0.75; + + if (headlineMatches && !nameMatches) { + metadata.title = parsed.headline; + } else { + metadata.title = parsed.name; + } + } else if (typeof parsed.name === "string") { + metadata.title = parsed.name.trim(); + } else if (typeof parsed.headline === "string") { + metadata.title = parsed.headline.trim(); + } + if (parsed.author) { + if (typeof parsed.author.name === "string") { + metadata.byline = parsed.author.name.trim(); + } else if ( + Array.isArray(parsed.author) && + parsed.author[0] && + typeof parsed.author[0].name === "string" + ) { + metadata.byline = parsed.author + .filter(function (author) { + return author && typeof author.name === "string"; + }) + .map(function (author) { + return author.name.trim(); + }) + .join(", "); + } + } + if (typeof parsed.description === "string") { + metadata.excerpt = parsed.description.trim(); + } + if (parsed.publisher && typeof parsed.publisher.name === "string") { + metadata.siteName = parsed.publisher.name.trim(); + } + if (typeof parsed.datePublished === "string") { + metadata.datePublished = parsed.datePublished.trim(); + } + } catch (err) { + this.log(err.message); + } + } + }); + return metadata ? metadata : {}; + }, + + /** + * Attempts to get excerpt and byline metadata for the article. + * + * @param {Object} jsonld — object containing any metadata that + * could be extracted from JSON-LD object. + * + * @return Object with optional "excerpt" and "byline" properties + */ + _getArticleMetadata (jsonld) { + var metadata = {}; + var values = {}; + var metaElements = this._doc.getElementsByTagName("meta"); + + // property is a space-separated list of values + var propertyPattern = + /\s*(article|dc|dcterm|og|twitter)\s*:\s*(author|creator|description|published_time|title|site_name)\s*/gi; + + // name is a single value + var namePattern = + /^\s*(?:(dc|dcterm|og|twitter|parsely|weibo:(article|webpage))\s*[-\.:]\s*)?(author|creator|pub-date|description|title|site_name)\s*$/i; + + // Find description tags. + this._forEachNode(metaElements, function (element) { + var elementName = element.getAttribute("name"); + var elementProperty = element.getAttribute("property"); + var content = element.getAttribute("content"); + if (!content) { + return; + } + var matches = null; + var name = null; + + if (elementProperty) { + matches = elementProperty.match(propertyPattern); + if (matches) { + // Convert to lowercase, and remove any whitespace + // so we can match below. + name = matches[0].toLowerCase().replace(/\s/g, ""); + // multiple authors + values[name] = content.trim(); + } + } + if (!matches && elementName && namePattern.test(elementName)) { + name = elementName; + if (content) { + // Convert to lowercase, remove any whitespace, and convert dots + // to colons so we can match below. + name = name.toLowerCase().replace(/\s/g, "").replace(/\./g, ":"); + values[name] = content.trim(); + } + } + }); + + // get title + metadata.title = + jsonld.title || + values["dc:title"] || + values["dcterm:title"] || + values["og:title"] || + values["weibo:article:title"] || + values["weibo:webpage:title"] || + values.title || + values["twitter:title"] || + values["parsely-title"]; + + if (!metadata.title) { + metadata.title = this._getArticleTitle(); + } + + const articleAuthor = + typeof values["article:author"] === "string" && + !this._isUrl(values["article:author"]) + ? values["article:author"] + : undefined; + + // get author + metadata.byline = + jsonld.byline || + values["dc:creator"] || + values["dcterm:creator"] || + values.author || + values["parsely-author"] || + articleAuthor; + + // get description + metadata.excerpt = + jsonld.excerpt || + values["dc:description"] || + values["dcterm:description"] || + values["og:description"] || + values["weibo:article:description"] || + values["weibo:webpage:description"] || + values.description || + values["twitter:description"]; + + // get site name + metadata.siteName = jsonld.siteName || values["og:site_name"]; + + // get article published time + metadata.publishedTime = + jsonld.datePublished || + values["article:published_time"] || + values["parsely-pub-date"] || + null; + + // in many sites the meta value is escaped with HTML entities, + // so here we need to unescape it + metadata.title = this._unescapeHtmlEntities(metadata.title); + metadata.byline = this._unescapeHtmlEntities(metadata.byline); + metadata.excerpt = this._unescapeHtmlEntities(metadata.excerpt); + metadata.siteName = this._unescapeHtmlEntities(metadata.siteName); + metadata.publishedTime = this._unescapeHtmlEntities(metadata.publishedTime); + + return metadata; + }, + + /** + * Check if node is image, or if node contains exactly only one image + * whether as a direct child or as its descendants. + * + * @param Element + **/ + _isSingleImage (node) { + while (node) { + if (node.tagName === "IMG") { + return true; + } + if (node.children.length !== 1 || node.textContent.trim() !== "") { + return false; + } + node = node.children[0]; + } + return false; + }, + + /** + * Find all