diff --git a/flymyai/__init__.py b/flymyai/__init__.py index 5542c2f..b19785c 100644 --- a/flymyai/__init__.py +++ b/flymyai/__init__.py @@ -2,8 +2,10 @@ from flymyai.core.client import FlyMyAI, AsyncFlyMyAI, FlyMyAIM1, AsyncFlymyAIM1 from flymyai.core.exceptions import FlyMyAIPredictException, FlyMyAIExceptionGroup +from flymyai.agents import AgentClient, AsyncAgentClient, FlyMyAIAgentError __all__ = [ + # Prediction clients "run", "httpx", "async_run", @@ -11,6 +13,10 @@ "AsyncFlyMyAI", "FlyMyAIExceptionGroup", "FlyMyAIPredictException", + # Agent clients + "AgentClient", + "AsyncAgentClient", + "FlyMyAIAgentError", ] diff --git a/flymyai/agents/__init__.py b/flymyai/agents/__init__.py new file mode 100644 index 0000000..da84cf2 --- /dev/null +++ b/flymyai/agents/__init__.py @@ -0,0 +1,45 @@ +from flymyai.agents._client import ( + AsyncAgentClient, + FlyMyAIAgentError, + SyncAgentClient, +) +from flymyai.agents._types import ( + Agent, + AgentDetail, + AgentStatus, + AvailableTool, + Compilation, + CompilationStatus, + ConfigurationStep, + ExecutionLog, + ExecutionLogType, + ExecutionStatus, + Run, + RunDetail, + Tool, +) + +# Convenience alias — docs use `AgentClient` +AgentClient = SyncAgentClient + +__all__ = [ + # Clients + "AgentClient", + "SyncAgentClient", + "AsyncAgentClient", + "FlyMyAIAgentError", + # Models + "Agent", + "AgentDetail", + "AgentStatus", + "AvailableTool", + "Compilation", + "CompilationStatus", + "ConfigurationStep", + "ExecutionLog", + "ExecutionLogType", + "ExecutionStatus", + "Run", + "RunDetail", + "Tool", +] diff --git a/flymyai/agents/_client.py b/flymyai/agents/_client.py new file mode 100644 index 0000000..fecbd63 --- /dev/null +++ b/flymyai/agents/_client.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import os +from typing import Any, Dict, Optional + +import httpx + +from flymyai.agents._resources import ( + Agents, + AsyncAgents, + AsyncCompilations, + AsyncRuns, + AsyncTools, + Compilations, + Runs, + Tools, +) + + +_DEFAULT_BASE_URL = "https://backend.flymy.ai" +_DEFAULT_TIMEOUT = 60.0 + + +class FlyMyAIAgentError(Exception): + """Raised when the Agents API returns a non-2xx response.""" + + def __init__( + self, + message: str, + *, + status_code: int, + response_body: Any = None, + ) -> None: + self.status_code = status_code + self.response_body = response_body + super().__init__(message) + + def __repr__(self) -> str: + return ( + f"FlyMyAIAgentError(status_code={self.status_code}, " + f"message={str(self)!r})" + ) + + +def _raise_for_status(resp: httpx.Response) -> None: + if resp.is_success: + return + try: + body = resp.json() + except Exception: + body = resp.text + detail = body.get("detail", body) if isinstance(body, dict) else body + raise FlyMyAIAgentError( + f"HTTP {resp.status_code}: {detail}", + status_code=resp.status_code, + response_body=body, + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Synchronous client +# ══════════════════════════════════════════════════════════════════════════════ + + +class SyncAgentClient: + """Synchronous client for the FlyMyAI Agents API. + + Usage:: + + from flymyai import AgentClient + + client = AgentClient(api_key="fly-...") + + agent = client.agents.create(name="Researcher", goal="Search the web") + run = client.agents.run(agent.id) + result = client.runs.wait(run.id) + print(result.output) + """ + + def __init__( + self, + api_key: Optional[str] = None, + *, + base_url: Optional[str] = None, + timeout: float = _DEFAULT_TIMEOUT, + max_retries: int = 2, + ) -> None: + self._api_key = api_key or os.environ.get("FLYMYAI_API_KEY", "") + if not self._api_key: + raise ValueError( + "api_key is required. Pass it directly or set FLYMYAI_API_KEY." + ) + self._base_url = ( + base_url + or os.environ.get("FLYMYAI_DSN") + or _DEFAULT_BASE_URL + ) + self._max_retries = max_retries + self._http = httpx.Client( + base_url=self._base_url, + headers={"X-API-KEY": self._api_key}, + timeout=httpx.Timeout(timeout), + ) + + # resource namespaces + self.agents = Agents(self) + self.runs = Runs(self) + self.tools = Tools(self) + self.compilations = Compilations(self) + + # -- low-level request ----------------------------------------------------- + + def _request( + self, method: str, path: str, **kwargs: Any + ) -> Any: + resp = self._http.request(method, path, **kwargs) + _raise_for_status(resp) + if resp.status_code == 204: + return None + return resp.json() + + # -- context manager ------------------------------------------------------- + + def __enter__(self) -> SyncAgentClient: + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def close(self) -> None: + self._http.close() + + +# ══════════════════════════════════════════════════════════════════════════════ +# Asynchronous client +# ══════════════════════════════════════════════════════════════════════════════ + + +class AsyncAgentClient: + """Async client for the FlyMyAI Agents API. + + Usage:: + + from flymyai import AsyncAgentClient + + async with AsyncAgentClient(api_key="fly-...") as client: + agent = await client.agents.create(name="Researcher", goal="Search the web") + run = await client.agents.run(agent.id) + result = await client.runs.wait(run.id) + print(result.output) + """ + + def __init__( + self, + api_key: Optional[str] = None, + *, + base_url: Optional[str] = None, + timeout: float = _DEFAULT_TIMEOUT, + max_retries: int = 2, + ) -> None: + self._api_key = api_key or os.environ.get("FLYMYAI_API_KEY", "") + if not self._api_key: + raise ValueError( + "api_key is required. Pass it directly or set FLYMYAI_API_KEY." + ) + self._base_url = ( + base_url + or os.environ.get("FLYMYAI_DSN") + or _DEFAULT_BASE_URL + ) + self._max_retries = max_retries + self._http = httpx.AsyncClient( + base_url=self._base_url, + headers={"X-API-KEY": self._api_key}, + timeout=httpx.Timeout(timeout), + ) + + # resource namespaces + self.agents = AsyncAgents(self) + self.runs = AsyncRuns(self) + self.tools = AsyncTools(self) + self.compilations = AsyncCompilations(self) + + # -- low-level request ----------------------------------------------------- + + async def _request( + self, method: str, path: str, **kwargs: Any + ) -> Any: + resp = await self._http.request(method, path, **kwargs) + _raise_for_status(resp) + if resp.status_code == 204: + return None + return resp.json() + + # -- context manager ------------------------------------------------------- + + async def __aenter__(self) -> AsyncAgentClient: + return self + + async def __aexit__(self, *exc: Any) -> None: + await self.close() + + async def close(self) -> None: + await self._http.aclose() diff --git a/flymyai/agents/_resources.py b/flymyai/agents/_resources.py new file mode 100644 index 0000000..31a217d --- /dev/null +++ b/flymyai/agents/_resources.py @@ -0,0 +1,512 @@ +from __future__ import annotations + +import asyncio +import time +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from flymyai.agents._types import ( + Agent, + AgentDetail, + AvailableTool, + Compilation, + Run, + RunDetail, + Tool, +) + +if TYPE_CHECKING: + from flymyai.agents._client import AsyncAgentClient, SyncAgentClient + + +# ── helpers ────────────────────────────────────────────────────────────────── + +_TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled"}) + + +# ══════════════════════════════════════════════════════════════════════════════ +# SYNC resources +# ══════════════════════════════════════════════════════════════════════════════ + + +class Agents: + """CRUD for agents. Maps to ``/api/v1/agents/tasks/``.""" + + def __init__(self, client: SyncAgentClient) -> None: + self._c = client + + # -- create ---------------------------------------------------------------- + + def create( + self, + *, + name: str, + goal: str, + tools: Optional[List[int]] = None, + status: Optional[str] = None, + ) -> Agent: + """Create a new agent. + + Parameters + ---------- + name: + Human-readable agent name. + goal: + The agent's prompt / instructions (stored as ``user_prompt``). + tools: + List of ``UserMcpTool`` IDs to attach. + status: + Initial status (default ``draft``). + """ + body: Dict[str, Any] = {"name": name, "user_prompt": goal} + if tools is not None: + body["available_tools"] = tools + if status is not None: + body["status"] = status + data = self._c._request("POST", "/api/v1/agents/tasks/", json=body) + return Agent(**data) + + # -- list ------------------------------------------------------------------ + + def list(self) -> List[Agent]: + """Return all non-archived agents for the current user.""" + data = self._c._request("GET", "/api/v1/agents/tasks/") + return [Agent(**item) for item in data] + + # -- get ------------------------------------------------------------------- + + def get(self, agent_id: str) -> AgentDetail: + """Get agent by UUID (returns full detail with nested tools).""" + data = self._c._request("GET", f"/api/v1/agents/tasks/{agent_id}/") + return AgentDetail(**data) + + # -- update ---------------------------------------------------------------- + + def update(self, agent_id: str, **kwargs: Any) -> Agent: + """Partial update (PATCH). + + Use ``goal=`` to update ``user_prompt``. + """ + if "goal" in kwargs: + kwargs["user_prompt"] = kwargs.pop("goal") + data = self._c._request( + "PATCH", f"/api/v1/agents/tasks/{agent_id}/", json=kwargs + ) + return Agent(**data) + + # -- delete ---------------------------------------------------------------- + + def delete(self, agent_id: str) -> None: + """Soft-delete (archive) an agent.""" + self._c._request("DELETE", f"/api/v1/agents/tasks/{agent_id}/") + + # -- run ------------------------------------------------------------------- + + def run(self, agent_id: str) -> RunDetail: + """Create an execution and start the agent loop. + + Returns the newly created :class:`RunDetail` (status will be ``pending``). + """ + data = self._c._request( + "POST", f"/api/v1/agents/tasks/{agent_id}/run-loop/" + ) + return RunDetail(**data) + + +class Runs: + """Manage agent executions (runs). Maps to ``/api/v1/agents/executions/``.""" + + def __init__(self, client: SyncAgentClient) -> None: + self._c = client + + def create(self, *, agent_id: str) -> RunDetail: + """Create a new run for the given agent. + + This is a convenience alias for ``client.agents.run(agent_id)``. + """ + return self._c.agents.run(agent_id) + + def list(self) -> List[Run]: + """List all executions for the current user (newest first).""" + data = self._c._request("GET", "/api/v1/agents/executions/") + return [Run(**item) for item in data] + + def get(self, run_id: int) -> RunDetail: + """Get a single execution with logs.""" + data = self._c._request("GET", f"/api/v1/agents/executions/{run_id}/") + return RunDetail(**data) + + def cancel(self, run_id: int) -> None: + """Cancel a running execution.""" + self._c._request("POST", f"/api/v1/agents/executions/{run_id}/cancel/") + + def append_message(self, run_id: int, *, text: str) -> RunDetail: + """Append a user message to the conversation and restart the agent loop.""" + data = self._c._request( + "POST", + f"/api/v1/agents/executions/{run_id}/append-message/", + json={"text": text}, + ) + return RunDetail(**data) + + def wait( + self, + run_id: int, + *, + timeout: float = 300, + poll_interval: float = 2.0, + ) -> RunDetail: + """Poll until the run reaches a terminal status. + + Parameters + ---------- + run_id: + Execution ID. + timeout: + Max seconds to wait before raising ``TimeoutError``. + poll_interval: + Seconds between polls. + """ + deadline = time.monotonic() + timeout + while True: + result = self.get(run_id) + if result.status in _TERMINAL_STATUSES: + return result + if time.monotonic() >= deadline: + raise TimeoutError( + f"Run {run_id} did not complete within {timeout}s " + f"(last status: {result.status})" + ) + time.sleep(poll_interval) + + def stream_events( + self, + run_id: int, + *, + timeout: float = 300, + poll_interval: float = 1.0, + ): + """Yield new :class:`ExecutionLog` entries as they appear. + + Polls the execution detail endpoint and yields logs that haven't been + seen yet. Stops when the run reaches a terminal status. + """ + seen_ids: set = set() + deadline = time.monotonic() + timeout + while True: + detail = self.get(run_id) + for log in detail.logs: + if log.id not in seen_ids: + seen_ids.add(log.id) + yield log + if detail.status in _TERMINAL_STATUSES: + return + if time.monotonic() >= deadline: + return + time.sleep(poll_interval) + + +class Tools: + """Manage MCP tools. Maps to ``/api/v1/agents/tools/``.""" + + def __init__(self, client: SyncAgentClient) -> None: + self._c = client + + def list(self) -> List[Tool]: + """List the user's configured tools.""" + data = self._c._request("GET", "/api/v1/agents/tools/") + return [Tool(**item) for item in data] + + def available(self) -> List[AvailableTool]: + """List available tools from the catalog (no auth required).""" + data = self._c._request("GET", "/api/v1/agents/tools/available/") + return [AvailableTool(**item) for item in data] + + def create(self, *, mcp_tool: str, **kwargs: Any) -> Tool: + """Add a tool to the user's account.""" + body = {"mcp_tool": mcp_tool, **kwargs} + data = self._c._request("POST", "/api/v1/agents/tools/", json=body) + return Tool(**data) + + def get(self, tool_id: int) -> Tool: + data = self._c._request("GET", f"/api/v1/agents/tools/{tool_id}/") + return Tool(**data) + + def update(self, tool_id: int, **kwargs: Any) -> Tool: + """Partial update (PATCH). Pass ``user_config={...}`` to merge config.""" + data = self._c._request( + "PATCH", f"/api/v1/agents/tools/{tool_id}/", json=kwargs + ) + return Tool(**data) + + def delete(self, tool_id: int) -> None: + self._c._request("DELETE", f"/api/v1/agents/tools/{tool_id}/") + + def provide_config( + self, tool_id: int, *, user_response: Any + ) -> Tool: + """Answer the current ``ask_user`` configuration step.""" + data = self._c._request( + "POST", + f"/api/v1/agents/tools/{tool_id}/provide-config/", + json={"user_response": user_response}, + ) + return Tool(**data) + + def call( + self, + tool_id: int, + *, + action: str, + arguments: Optional[Dict[str, Any]] = None, + ) -> Any: + """Invoke a custom-class tool action directly.""" + data = self._c._request( + "POST", + f"/api/v1/agents/tools/{tool_id}/call/", + json={"action": action, "arguments": arguments or {}}, + ) + return data + + +class Compilations: + """Script compilations. Maps to ``/api/v1/agents/compilations/``.""" + + def __init__(self, client: SyncAgentClient) -> None: + self._c = client + + def list(self) -> List[Compilation]: + data = self._c._request("GET", "/api/v1/agents/compilations/") + return [Compilation(**item) for item in data] + + def get(self, compilation_id: int) -> Compilation: + data = self._c._request( + "GET", f"/api/v1/agents/compilations/{compilation_id}/" + ) + return Compilation(**data) + + def compile(self, *, execution_id: int) -> Compilation: + """Create a script compilation from an execution.""" + data = self._c._request( + "POST", f"/api/v1/agents/compilations/compile/{execution_id}/" + ) + return Compilation(**data) + + def run(self, compilation_id: int) -> Compilation: + """Execute a compiled script.""" + data = self._c._request( + "POST", f"/api/v1/agents/compilations/{compilation_id}/run/" + ) + return Compilation(**data) + + +# ══════════════════════════════════════════════════════════════════════════════ +# ASYNC resources +# ══════════════════════════════════════════════════════════════════════════════ + + +class AsyncAgents: + """Async variant of :class:`Agents`.""" + + def __init__(self, client: AsyncAgentClient) -> None: + self._c = client + + async def create( + self, + *, + name: str, + goal: str, + tools: Optional[List[int]] = None, + status: Optional[str] = None, + ) -> Agent: + body: Dict[str, Any] = {"name": name, "user_prompt": goal} + if tools is not None: + body["available_tools"] = tools + if status is not None: + body["status"] = status + data = await self._c._request("POST", "/api/v1/agents/tasks/", json=body) + return Agent(**data) + + async def list(self) -> List[Agent]: + data = await self._c._request("GET", "/api/v1/agents/tasks/") + return [Agent(**item) for item in data] + + async def get(self, agent_id: str) -> AgentDetail: + data = await self._c._request("GET", f"/api/v1/agents/tasks/{agent_id}/") + return AgentDetail(**data) + + async def update(self, agent_id: str, **kwargs: Any) -> Agent: + if "goal" in kwargs: + kwargs["user_prompt"] = kwargs.pop("goal") + data = await self._c._request( + "PATCH", f"/api/v1/agents/tasks/{agent_id}/", json=kwargs + ) + return Agent(**data) + + async def delete(self, agent_id: str) -> None: + await self._c._request("DELETE", f"/api/v1/agents/tasks/{agent_id}/") + + async def run(self, agent_id: str) -> RunDetail: + data = await self._c._request( + "POST", f"/api/v1/agents/tasks/{agent_id}/run-loop/" + ) + return RunDetail(**data) + + +class AsyncRuns: + """Async variant of :class:`Runs`.""" + + def __init__(self, client: AsyncAgentClient) -> None: + self._c = client + + async def create(self, *, agent_id: str) -> RunDetail: + """Create a new run for the given agent (async).""" + return await self._c.agents.run(agent_id) + + async def list(self) -> List[Run]: + data = await self._c._request("GET", "/api/v1/agents/executions/") + return [Run(**item) for item in data] + + async def get(self, run_id: int) -> RunDetail: + data = await self._c._request( + "GET", f"/api/v1/agents/executions/{run_id}/" + ) + return RunDetail(**data) + + async def cancel(self, run_id: int) -> None: + await self._c._request( + "POST", f"/api/v1/agents/executions/{run_id}/cancel/" + ) + + async def append_message(self, run_id: int, *, text: str) -> RunDetail: + data = await self._c._request( + "POST", + f"/api/v1/agents/executions/{run_id}/append-message/", + json={"text": text}, + ) + return RunDetail(**data) + + async def wait( + self, + run_id: int, + *, + timeout: float = 300, + poll_interval: float = 2.0, + ) -> RunDetail: + deadline = time.monotonic() + timeout + while True: + result = await self.get(run_id) + if result.status in _TERMINAL_STATUSES: + return result + if time.monotonic() >= deadline: + raise TimeoutError( + f"Run {run_id} did not complete within {timeout}s " + f"(last status: {result.status})" + ) + await asyncio.sleep(poll_interval) + + async def stream_events( + self, + run_id: int, + *, + timeout: float = 300, + poll_interval: float = 1.0, + ): + seen_ids: set = set() + deadline = time.monotonic() + timeout + while True: + detail = await self.get(run_id) + for log in detail.logs: + if log.id not in seen_ids: + seen_ids.add(log.id) + yield log + if detail.status in _TERMINAL_STATUSES: + return + if time.monotonic() >= deadline: + return + await asyncio.sleep(poll_interval) + + +class AsyncTools: + """Async variant of :class:`Tools`.""" + + def __init__(self, client: AsyncAgentClient) -> None: + self._c = client + + async def list(self) -> List[Tool]: + data = await self._c._request("GET", "/api/v1/agents/tools/") + return [Tool(**item) for item in data] + + async def available(self) -> List[AvailableTool]: + data = await self._c._request("GET", "/api/v1/agents/tools/available/") + return [AvailableTool(**item) for item in data] + + async def create(self, *, mcp_tool: str, **kwargs: Any) -> Tool: + body = {"mcp_tool": mcp_tool, **kwargs} + data = await self._c._request("POST", "/api/v1/agents/tools/", json=body) + return Tool(**data) + + async def get(self, tool_id: int) -> Tool: + data = await self._c._request("GET", f"/api/v1/agents/tools/{tool_id}/") + return Tool(**data) + + async def update(self, tool_id: int, **kwargs: Any) -> Tool: + data = await self._c._request( + "PATCH", f"/api/v1/agents/tools/{tool_id}/", json=kwargs + ) + return Tool(**data) + + async def delete(self, tool_id: int) -> None: + await self._c._request("DELETE", f"/api/v1/agents/tools/{tool_id}/") + + async def provide_config( + self, tool_id: int, *, user_response: Any + ) -> Tool: + data = await self._c._request( + "POST", + f"/api/v1/agents/tools/{tool_id}/provide-config/", + json={"user_response": user_response}, + ) + return Tool(**data) + + async def call( + self, + tool_id: int, + *, + action: str, + arguments: Optional[Dict[str, Any]] = None, + ) -> Any: + data = await self._c._request( + "POST", + f"/api/v1/agents/tools/{tool_id}/call/", + json={"action": action, "arguments": arguments or {}}, + ) + return data + + +class AsyncCompilations: + """Async variant of :class:`Compilations`.""" + + def __init__(self, client: AsyncAgentClient) -> None: + self._c = client + + async def list(self) -> List[Compilation]: + data = await self._c._request("GET", "/api/v1/agents/compilations/") + return [Compilation(**item) for item in data] + + async def get(self, compilation_id: int) -> Compilation: + data = await self._c._request( + "GET", f"/api/v1/agents/compilations/{compilation_id}/" + ) + return Compilation(**data) + + async def compile(self, *, execution_id: int) -> Compilation: + data = await self._c._request( + "POST", f"/api/v1/agents/compilations/compile/{execution_id}/" + ) + return Compilation(**data) + + async def run(self, compilation_id: int) -> Compilation: + data = await self._c._request( + "POST", f"/api/v1/agents/compilations/{compilation_id}/run/" + ) + return Compilation(**data) diff --git a/flymyai/agents/_types.py b/flymyai/agents/_types.py new file mode 100644 index 0000000..86cf10c --- /dev/null +++ b/flymyai/agents/_types.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +# ── Enums ──────────────────────────────────────────────────────────────────── + + +class AgentStatus(str, Enum): + DRAFT = "draft" + INITIALIZATION_REQUIRED = "initialization_required" + ACTIVE = "active" + ARCHIVED = "archived" + + +class ExecutionStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class CompilationStatus(str, Enum): + PENDING = "pending" + COMPILING = "compiling" + COMPILED = "compiled" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +class ExecutionLogType(str, Enum): + DECLARED_FUNCTIONS = "declared_functions" + TOOL_CALLED = "tool_called" + TOOL_CALL_EXCEPTION = "tool_call_exception" + TASK_CANCELLED = "task_cancelled" + + +# ── Agent (backend: UserAgentTask) ─────────────────────────────────────────── + + +class Agent(BaseModel): + """An agent task — the top-level configuration for an autonomous agent.""" + + uuid: str + name: str + user_prompt: str + available_tools: Any = Field(default_factory=list) + all_tools_configured: bool = False + tools_need_to_configure: List[int] = Field(default_factory=list) + generated_pipeline: Dict[str, Any] = Field(default_factory=dict) + status: AgentStatus = AgentStatus.DRAFT + created_at: datetime + updated_at: datetime + + @property + def id(self) -> str: + return self.uuid + + @property + def goal(self) -> str: + return self.user_prompt + + +class AgentDetail(Agent): + """Agent with nested tool objects instead of IDs.""" + + available_tools: List[Dict[str, Any]] = Field(default_factory=list) + + +# ── Execution / Run (backend: UserAgentExecution) ─────────────────────────── + + +class ExecutionLog(BaseModel): + id: int + created_at: datetime + updated_at: datetime + type: ExecutionLogType + message: str + data: Any = Field(default_factory=dict) + + +class Run(BaseModel): + """A single agent execution (run).""" + + id: int + user_agent_task: int + previous_execution: Optional[int] = None + original_prompt: str + created_at: datetime + updated_at: datetime + messages: List[Dict[str, Any]] = Field(default_factory=list) + status: ExecutionStatus = ExecutionStatus.PENDING + run_seq: int = 0 + error: Optional[str] = None + agent_result: Optional[Dict[str, Any]] = None + + @property + def output(self) -> Optional[Dict[str, Any]]: + return self.agent_result + + @property + def is_terminal(self) -> bool: + return self.status in ( + ExecutionStatus.COMPLETED, + ExecutionStatus.FAILED, + ExecutionStatus.CANCELLED, + ) + + +class RunDetail(Run): + """Run with execution logs attached.""" + + logs: List[ExecutionLog] = Field(default_factory=list) + user_agent_task_uuid: Optional[str] = None + + +# ── Tool (backend: UserMcpTool) ───────────────────────────────────────────── + + +class ConfigurationStep(BaseModel): + description: str + step_type: str + vars_from_user_schema: Optional[Any] = None + configuration_schema: Optional[Any] = None + config: Optional[Any] = None + execution_command: Optional[str] = None + + +class Tool(BaseModel): + """A configured MCP tool belonging to the user.""" + + id: int + mcp_tool: str + user_config: Dict[str, Any] = Field(default_factory=dict) + is_configured: bool = False + is_active: bool = True + unsafe_methods: List[str] = Field(default_factory=list) + required_configuration_steps: List[ConfigurationStep] = Field(default_factory=list) + finished_configuration_steps: List[Dict[str, Any]] = Field(default_factory=list) + next_configuration_step: Optional[Dict[str, Any]] = None + redirect_url: str = "" + response: str = "" + created_at: datetime + updated_at: datetime + + @property + def name(self) -> str: + return self.mcp_tool + + +class AvailableTool(BaseModel): + """An MCP tool from the catalog that can be added to a user's account.""" + + name: str + type: str + title: str = "" + description: str = "" + detail: str = "" + href: str = "" + categories: List[str] = Field(default_factory=list) + instruction: Optional[str] = None + custom_class: Optional[str] = None + github_link: Optional[str] = None + configuration_steps: List[Dict[str, Any]] = Field(default_factory=list) + + +# ── Script Compilation ────────────────────────────────────────────────────── + + +class Compilation(BaseModel): + """A compiled script derived from an agent execution.""" + + id: int + execution: int + status: CompilationStatus = CompilationStatus.PENDING + script_code: str = "" + result: Optional[Dict[str, Any]] = None + error: Optional[str] = None + created_at: datetime + updated_at: datetime