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"
.
+ * Whitespace between
elements are ignored. For example:
+ *
abc
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