diff --git a/.github/workflows/docker-image.yaml b/.github/workflows/docker-image.yaml index a9715ba..6262e76 100644 --- a/.github/workflows/docker-image.yaml +++ b/.github/workflows/docker-image.yaml @@ -1,8 +1,6 @@ name: Build and Publish Docker Image on: - push: - branches: [main] workflow_dispatch: env: diff --git a/packages/lobsterx/config.api.json b/packages/lobsterx/config.api.json new file mode 100644 index 0000000..db56a0e --- /dev/null +++ b/packages/lobsterx/config.api.json @@ -0,0 +1,9 @@ +{ + "allow_origins": [], + "file_downloads_per_minute": 300, + "create_tasks_per_minute": 60, + "delete_tasks_per_minute": 60, + "poll_tasks_per_minute": 300, + "host": "0.0.0.0", + "port": 9000 +} diff --git a/packages/lobsterx/pyproject.toml b/packages/lobsterx/pyproject.toml index 8920934..9bc4442 100644 --- a/packages/lobsterx/pyproject.toml +++ b/packages/lobsterx/pyproject.toml @@ -4,15 +4,19 @@ build-backend = "uv_build" [project] name = "lobsterx" -version = "0.1.1-beta" +version = "0.2.0-beta" description = "Background AI assistant working as a Telegram bot, built specifically for document-related use cases" readme = "README.md" requires-python = ">=3.11" dependencies = [ "aiofiles>=25.1.0", "diskcache>=5.6.3", + "fastapi>=0.129.0", + "fastapi-throttle>=0.1.8", + "httpx>=0.28.1", "llama-cloud>=1.2.0,<1.3", "python-dotenv>=1.2.1", + "python-multipart>=0.0.21", "python-telegram-bot>=22.6", "random-name>=0.1.1", "workflows-acp", diff --git a/packages/lobsterx/src/lobsterx/api/__init__.py b/packages/lobsterx/src/lobsterx/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/lobsterx/src/lobsterx/api/api.py b/packages/lobsterx/src/lobsterx/api/api.py new file mode 100644 index 0000000..2d3f855 --- /dev/null +++ b/packages/lobsterx/src/lobsterx/api/api.py @@ -0,0 +1,132 @@ +import asyncio +import mimetypes +import os + +from fastapi import FastAPI, HTTPException +from fastapi.datastructures import UploadFile +from fastapi.param_functions import Depends, File +from fastapi_throttle import RateLimiter +from random_name import generate_name +from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.middleware.cors import CORSMiddleware +from starlette.responses import JSONResponse + +from ..constants import DATA_DIR +from ..utils import _download_file_to_agentfs, handle_prompt +from .auth import LobsterXAuthentication, on_auth_error +from .shared import ( + GetTaskResponse, + TaskRequest, + TaskResponse, + UploadFileResponse, + get_server_key_from_env, + validate_api_key, +) +from .task_manager import get_task_manager + +DEFAULT_FILE_DOWNLOADS_PER_MINUTE = 300 +DEFAULT_TASKS_PER_MINUTE = 60 +DEFAULT_DELETE_TASKS_PER_MINUTE = 60 +DEFAULT_POLL_TASKS_PER_MINUTE = 300 + + +def _get_file_name(document: UploadFile) -> str: + extension = ( + mimetypes.guess_extension(document.content_type or "application/pdf") or ".pdf" + ) + if document.filename is None: + return generate_name() + extension + else: + if document.filename.endswith(extension): + return document.filename + return document.filename + extension + + +def create_api_app( + allow_origins: list[str], + file_downloads_per_minute: int | None, + create_tasks_per_minute: int | None, + delete_tasks_per_minute: int | None, + poll_tasks_per_minute: int | None, + server_api_key: str | None, +) -> FastAPI: + app = FastAPI() + + file_downloads_per_minute = ( + file_downloads_per_minute or DEFAULT_FILE_DOWNLOADS_PER_MINUTE + ) + tasks_per_minute = create_tasks_per_minute or DEFAULT_TASKS_PER_MINUTE + delete_tasks_per_minute = delete_tasks_per_minute or DEFAULT_DELETE_TASKS_PER_MINUTE + poll_tasks_per_minute = poll_tasks_per_minute or DEFAULT_POLL_TASKS_PER_MINUTE + api_key = server_api_key or get_server_key_from_env() + if api_key is None: + raise ValueError( + "API key not provided and `LOBSTERX_SERVER_KEY` not found within the current environment" + ) + + if not validate_api_key(api_key): + raise ValueError( + "API key should be an a string of letters, numbers, hyphens and underscores, with a minimum length of 32." + ) + + app.add_middleware( + CORSMiddleware, # type: ignore[invalid-argument-type] + allow_origins=allow_origins, + allow_methods=["POST", "GET", "DELETE"], + allow_headers=["Content-Type", "Authorization"], + ) + + app.add_middleware( + AuthenticationMiddleware, # type: ignore[invalid-argument-type] + backend=LobsterXAuthentication(api_key=api_key), + on_error=on_auth_error, + ) + + @app.post( + "/files", + dependencies=[ + Depends(RateLimiter(times=file_downloads_per_minute, seconds=60)) + ], + ) + async def download_file( + file: UploadFile = File(...), + ) -> UploadFileResponse: + file_name = _get_file_name(file) + file_content = await file.read() + path = os.path.join(DATA_DIR, file_name) + await _download_file_to_agentfs(path, file_content) + return UploadFileResponse(new_file_path=path) + + @app.post( + "/tasks", + dependencies=[Depends(RateLimiter(times=tasks_per_minute, seconds=60))], + ) + async def create_task(request: TaskRequest) -> TaskResponse: + task_manager = get_task_manager() + task = asyncio.create_task(handle_prompt(request.prompt)) + task_id = await task_manager.add_task(task) + return TaskResponse(task_id=task_id) + + @app.delete( + "/tasks/{task_id}", + dependencies=[Depends(RateLimiter(times=delete_tasks_per_minute, seconds=60))], + ) + async def cancel_task(task_id: str) -> JSONResponse: + task_manager = get_task_manager() + await task_manager.cancel_task(task_id) + return JSONResponse(status_code=204, content={}) + + @app.get( + "/tasks/{task_id}", + dependencies=[Depends(RateLimiter(times=poll_tasks_per_minute, seconds=60))], + ) + async def get_task(task_id: str) -> GetTaskResponse: + task_manager = get_task_manager() + task = await task_manager.check_task(task_id) + if task is None: + raise HTTPException( + status_code=404, detail=f"Task {task_id} does not exist" + ) + return GetTaskResponse.from_dataclass(task) + + return app diff --git a/packages/lobsterx/src/lobsterx/api/auth.py b/packages/lobsterx/src/lobsterx/api/auth.py new file mode 100644 index 0000000..eec6161 --- /dev/null +++ b/packages/lobsterx/src/lobsterx/api/auth.py @@ -0,0 +1,55 @@ +from starlette.authentication import ( + AuthCredentials, + AuthenticationBackend, + AuthenticationError, + BaseUser, +) +from starlette.requests import HTTPConnection +from starlette.responses import PlainTextResponse, Response + +from .shared import get_auth_header_pattern + +DEFAULT_DISPLAY_NAME = "lobsterx" +DEFAULT_IDENTITY = "user" + + +class LobsterXUser(BaseUser): + def __init__(self, authenticated: bool) -> None: + self._authenticated = authenticated + + @property + def is_authenticated(self) -> bool: + return self._authenticated + + @property + def identity(self) -> str: + return DEFAULT_IDENTITY + + @property + def display_name(self) -> str: + return DEFAULT_DISPLAY_NAME + + +class LobsterXAuthentication(AuthenticationBackend): + def __init__(self, api_key: str) -> None: + self.api_key = api_key + + async def authenticate( + self, conn: HTTPConnection + ) -> tuple[AuthCredentials, BaseUser] | None: + auth_header = conn.headers.get("Authorization", None) + if auth_header is None: + raise AuthenticationError("No authorization header in request") + matches = get_auth_header_pattern().findall(auth_header) + try: + assert len(matches) == 1, "Should only provide one bearer token" + except AssertionError as e: + raise AuthenticationError("Should only provide one bearer token") from e + api_key = matches[0] + if api_key == self.api_key: + return AuthCredentials(scopes=["http"]), LobsterXUser(authenticated=True) + raise AuthenticationError("API key not authorized") + + +def on_auth_error(conn: HTTPConnection, exc: AuthenticationError) -> Response: + return PlainTextResponse(str(exc), status_code=401) diff --git a/packages/lobsterx/src/lobsterx/api/client.py b/packages/lobsterx/src/lobsterx/api/client.py new file mode 100644 index 0000000..db5ea76 --- /dev/null +++ b/packages/lobsterx/src/lobsterx/api/client.py @@ -0,0 +1,83 @@ +import os +from mimetypes import guess_type +from typing import Literal + +from httpx import AsyncClient + +from .shared import ( + GetTaskResponse, + TaskRequest, + TaskResponse, + UploadFileResponse, + get_server_key_from_env, + validate_api_key, +) + + +class LobsterXClient: + def __init__( + self, + api_key: str | None, + host: str, + port: int, + protocol: Literal["http", "https"], + ) -> None: + self.base_url = f"{protocol}://{host}:{port}" + self.api_key = api_key or get_server_key_from_env() + if self.api_key is None: + raise ValueError( + "API key not provided and `LOBSTERX_SERVER_KEY` not found within the current environment" + ) + if not validate_api_key(self.api_key): + raise ValueError( + "API key should be an a string of letters, numbers, hyphens and underscores, with a minimum length of 32." + ) + + async def upload_file(self, file_path: str) -> str: + async with AsyncClient( + base_url=self.base_url, + headers={"Authorization": f"Bearer {self.api_key}"}, + timeout=600, + ) as client: + with open(file_path, "rb") as f: + mimetype, _ = guess_type(file_path) + file_type = mimetype or "application/pdf" + file = (os.path.basename(file_path), f, file_type) + response = await client.post("/files", files={"file": file}) + response.raise_for_status() + payload = response.json() + validated = UploadFileResponse.model_validate(payload) + return validated.new_file_path + + async def create_task(self, prompt: str) -> str: + async with AsyncClient( + base_url=self.base_url, + headers={"Authorization": f"Bearer {self.api_key}"}, + timeout=600, + ) as client: + payload = TaskRequest(prompt=prompt).model_dump() + response = await client.post("/tasks", json=payload) + response.raise_for_status() + json_response = response.json() + validated = TaskResponse.model_validate(json_response) + return validated.task_id + + async def get_task(self, task_id: str) -> GetTaskResponse: + async with AsyncClient( + base_url=self.base_url, + headers={"Authorization": f"Bearer {self.api_key}"}, + timeout=600, + ) as client: + response = await client.get(f"/tasks/{task_id}") + response.raise_for_status() + json_response = response.json() + return GetTaskResponse.model_validate(json_response) + + async def cancel_task(self, task_id: str) -> None: + async with AsyncClient( + base_url=self.base_url, + headers={"Authorization": f"Bearer {self.api_key}"}, + timeout=600, + ) as client: + response = await client.delete(f"/tasks/{task_id}") + response.raise_for_status() diff --git a/packages/lobsterx/src/lobsterx/api/shared.py b/packages/lobsterx/src/lobsterx/api/shared.py new file mode 100644 index 0000000..9b6add3 --- /dev/null +++ b/packages/lobsterx/src/lobsterx/api/shared.py @@ -0,0 +1,85 @@ +import functools +import json +import os +import re +from dataclasses import dataclass +from typing import Any, Literal + +from dotenv import load_dotenv +from pydantic import BaseModel + +from .task_manager import StatusEnum, TaskRepr + + +@dataclass +class LobsterXApiConfig: + allow_origins: list[str] + file_downloads_per_minute: int | None = None + create_tasks_per_minute: int | None = None + delete_tasks_per_minute: int | None = None + poll_tasks_per_minute: int | None = None + server_api_key: str | None = None + host: str | None = None + port: int | None = None + protocol: Literal["http", "https"] = "http" + + @classmethod + def load_from_config(cls, config_file: str) -> "LobsterXApiConfig": + with open(config_file, "r") as f: + config = json.load(f) + return cls(**config) + + def to_args(self) -> dict[str, Any]: + return { + "allow_origins": self.allow_origins, + "file_downloads_per_minute": self.file_downloads_per_minute, + "create_tasks_per_minute": self.create_tasks_per_minute, + "delete_tasks_per_minute": self.delete_tasks_per_minute, + "poll_tasks_per_minute": self.poll_tasks_per_minute, + "server_api_key": self.server_api_key, + } + + +@functools.lru_cache(maxsize=1) +def get_api_key_pattern() -> re.Pattern: + return re.compile(r"[a-zA-Z0-9_-]{32,}") + + +@functools.lru_cache(maxsize=1) +def get_auth_header_pattern() -> re.Pattern: + return re.compile(r"Bearer\s([a-zA-Z0-9_-]{32,})") + + +def validate_api_key(api_key: str) -> bool: + pattern = get_api_key_pattern() + return pattern.match(api_key) is not None + + +@functools.lru_cache(maxsize=1) +def get_server_key_from_env() -> str | None: + load_dotenv(".env") + return os.getenv("LOBSTERX_SERVER_KEY") + + +class TaskRequest(BaseModel): + prompt: str + + +class TaskResponse(BaseModel): + task_id: str + + +class GetTaskResponse(BaseModel): + status: StatusEnum + output: tuple[str, str] | None = None + error: str | None = None + + @classmethod + def from_dataclass(cls, task_repr: TaskRepr) -> "GetTaskResponse": + return cls( + status=task_repr.status, output=task_repr.output, error=task_repr.error + ) + + +class UploadFileResponse(BaseModel): + new_file_path: str diff --git a/packages/lobsterx/src/lobsterx/api/task_manager.py b/packages/lobsterx/src/lobsterx/api/task_manager.py new file mode 100644 index 0000000..4d5c41b --- /dev/null +++ b/packages/lobsterx/src/lobsterx/api/task_manager.py @@ -0,0 +1,70 @@ +import asyncio +import functools +import uuid +from dataclasses import dataclass +from enum import Enum + + +class StatusEnum(Enum): + SUCCESS = "success" + FAILED = "failed" + PENDING = "pending" + CANCELLED = "cancelled" + + +@dataclass +class TaskRepr: + status: StatusEnum + output: tuple[str, str] | None = None + error: str | None = None + + +class InMemoryTaskManager: + def __init__(self) -> None: + self._tasks: dict[str, asyncio.Task[tuple[str, str]]] = {} + self._lock: asyncio.Lock = asyncio.Lock() + + async def add_task(self, task: asyncio.Task[tuple[str, str]]) -> str: + id_ = str(uuid.uuid4()) + async with self._lock: + self._tasks[id_] = task + return id_ + + async def check_task(self, id_: str) -> TaskRepr | None: + task = self._tasks.get(id_) + if task is None: + return task + if task.done(): + if (e := task.exception()) is not None: + async with self._lock: + self._tasks.pop(id_) + return TaskRepr(status=StatusEnum.FAILED, error=str(e)) + result = task.result() + async with self._lock: + self._tasks.pop(id_) + return TaskRepr(status=StatusEnum.SUCCESS, output=result) + if task.cancelled() or task.cancelling(): + try: + await task + except asyncio.CancelledError: + async with self._lock: + self._tasks.pop(id_) + return TaskRepr(status=StatusEnum.CANCELLED) + return TaskRepr(status=StatusEnum.PENDING) + + async def cancel_task(self, id_: str) -> None: + task = self._tasks.get(id_) + if task is None: + return task + task.cancel() + try: + await task + except asyncio.CancelledError: + async with self._lock: + self._tasks.pop(id_) + return None + + +@functools.lru_cache(maxsize=1) +def get_task_manager() -> InMemoryTaskManager: + return InMemoryTaskManager() diff --git a/packages/lobsterx/src/lobsterx/cli.py b/packages/lobsterx/src/lobsterx/cli.py index 492f2cf..401e956 100644 --- a/packages/lobsterx/src/lobsterx/cli.py +++ b/packages/lobsterx/src/lobsterx/cli.py @@ -2,11 +2,17 @@ from pathlib import Path from typing import Annotated, Literal +import uvicorn from dotenv import set_key +from rich import print as rprint +from rich.markdown import Markdown from rich.prompt import Prompt from typer import Option, Typer from workflows_acp.constants import AVAILABLE_MODELS +from .api.api import create_api_app +from .api.client import LobsterXClient +from .api.shared import LobsterXApiConfig from .bot import run_bot from .constants import LOG_LEVELS @@ -61,6 +67,14 @@ def setup_wizard( help="Token for Telegram Bot. If not set, you will be prompted to provide it through standard input.", ), ] = None, + server_key: Annotated[ + str | None, + Option( + "--server-key", + "-s", + help="API key for the server. If not set, you will be prompted to provide it through standard input.", + ), + ] = None, interactive: Annotated[ bool, Option( @@ -90,6 +104,9 @@ def setup_wizard( ) llama_cloud_api_key = Prompt().ask("API key for LlamaCloud", password=True) telegram_token = Prompt().ask("Bot Token for Telegram", password=True) + server_key = Prompt().ask( + "Key for the LobsterX server (optional)", password=True + ) if api_key is None: api_key = Prompt().ask( "API key for model provider", @@ -99,11 +116,355 @@ def setup_wizard( llama_cloud_api_key = Prompt().ask("API key for LlamaCloud", password=True) if telegram_token is None: telegram_token = Prompt().ask("Bot Token for Telegram", password=True) + if server_key is None: + server_key = Prompt().ask( + "Key for the LobsterX server (optional)", password=True + ) dot_env = Path(".env") if not dot_env.exists(): dot_env.touch() set_key(".env", key_to_set="LOBSTERX_LLM_PROVIDER", value_to_set=llm_provider) set_key(".env", key_to_set="LOBSTERX_LLM_MODEL", value_to_set=llm_model) set_key(".env", key_to_set="LOBSTERX_LLM_API_KEY", value_to_set=api_key) + set_key(".env", key_to_set="LOBSTERX_SERVER_KEY", value_to_set=server_key) set_key(".env", key_to_set="LLAMA_CLOUD_API_KEY", value_to_set=llama_cloud_api_key) set_key(".env", key_to_set="TELEGRAM_BOT_TOKEN", value_to_set=telegram_token) + + +@app.command(name="serve", help="Run LobsterX as an API server.") +def serve( + host: Annotated[ + str, + Option( + "--bind", + "-b", + help="Host to bind the server to. Defaults to 0.0.0.0", + ), + ] = "0.0.0.0", + port: Annotated[ + int, + Option( + "--port", + "-p", + help="Port to bind the server to. Defaults to 8000", + ), + ] = 8000, + allow_origins: Annotated[ + list[str], + Option( + "--allow", + "-a", + help="Origins to be allowed for CORS (can be used multiple times)", + ), + ] = [], + file_downloads_per_minute: Annotated[ + int | None, + Option( + "--file-downloads-per-minute", + "-a", + help="Rate limit (per minute) on file downloads. Defaults to 300.", + ), + ] = None, + create_tasks_per_minute: Annotated[ + int | None, + Option( + "--create-tasks-per-minute", + help="Rate limit (per minute) on task creation. Defaults to 60.", + ), + ] = None, + delete_tasks_per_minute: Annotated[ + int | None, + Option( + "--delete-tasks-per-minute", + help="Rate limit (per minute) on task cancellation. Defaults to 60.", + ), + ] = None, + poll_tasks_per_minute: Annotated[ + int | None, + Option( + "--poll-tasks-per-minute", + help="Rate limit (per minute) on polling tasks for completion. Defaults to 300.", + ), + ] = None, + server_api_key: Annotated[ + str | None, + Option( + "--server-key", + help="API key to be used within the server to authorize requests. Reads from LOBSTERX_SERVER_KEY env variable if not provided.", + ), + ] = None, + config_file: Annotated[ + str | None, + Option( + "--config", + "-c", + help="Config file from which to read the LobsterX server configuration. Configured options have precedence over CLI.", + ), + ] = None, +) -> None: + if config_file is not None: + args = LobsterXApiConfig.load_from_config(config_file) + app = create_api_app(**args.to_args()) + port = args.port or port + host = args.host or host + else: + app = create_api_app( + allow_origins=allow_origins, + create_tasks_per_minute=create_tasks_per_minute, + delete_tasks_per_minute=delete_tasks_per_minute, + poll_tasks_per_minute=poll_tasks_per_minute, + file_downloads_per_minute=file_downloads_per_minute, + server_api_key=server_api_key, + ) + uvicorn.run(app, host=host, port=port) + + +@app.command( + name="create-task", help="Send a request to a LobsterX server to create a task." +) +def create_task( + prompt: str, + protocol: Annotated[ + Literal["http", "https"], + Option( + "--protocol", + "-t", + help="Protocol for the connection. Defaults to 'http'.", + ), + ] = "http", + host: Annotated[ + str, + Option( + "--bind", + "-b", + help="Host to bind the server to. Defaults to 0.0.0.0", + ), + ] = "0.0.0.0", + port: Annotated[ + int, + Option( + "--port", + "-p", + help="Port to bind the server to. Defaults to 8000", + ), + ] = 8000, + server_api_key: Annotated[ + str | None, + Option( + "--server-key", + help="API key to be used within the server to authorize requests. Reads from LOBSTERX_SERVER_KEY env variable if not provided.", + ), + ] = None, + config_file: Annotated[ + str | None, + Option( + "--config", + "-c", + help="Config file from which to read the LobsterX server configuration. Configured options have precedence over CLI.", + ), + ] = None, +) -> None: + if config_file is not None: + args = LobsterXApiConfig.load_from_config(config_file) + port = args.port or port + host = args.host or host + protocol = args.protocol or protocol + client = LobsterXClient( + api_key=server_api_key, host=host, port=port, protocol=protocol + ) + response = asyncio.run(client.create_task(prompt)) + print( + f"Created task as: {response}. Please use this Task ID to poll for the result or cancel the task." + ) + + +@app.command( + name="upload-file", help="Send a request to a LobsterX server to upload a file." +) +def upload_file( + file_path: str, + protocol: Annotated[ + Literal["http", "https"], + Option( + "--protocol", + "-t", + help="Protocol for the connection. Defaults to 'http'.", + ), + ] = "http", + host: Annotated[ + str, + Option( + "--bind", + "-b", + help="Host to bind the server to. Defaults to 0.0.0.0", + ), + ] = "0.0.0.0", + port: Annotated[ + int, + Option( + "--port", + "-p", + help="Port to bind the server to. Defaults to 8000", + ), + ] = 8000, + server_api_key: Annotated[ + str | None, + Option( + "--server-key", + help="API key to be used within the server to authorize requests. Reads from LOBSTERX_SERVER_KEY env variable if not provided.", + ), + ] = None, + config_file: Annotated[ + str | None, + Option( + "--config", + "-c", + help="Config file from which to read the LobsterX server configuration. Configured options have precedence over CLI.", + ), + ] = None, +) -> None: + if config_file is not None: + args = LobsterXApiConfig.load_from_config(config_file) + port = args.port or port + host = args.host or host + protocol = args.protocol or protocol + client = LobsterXClient( + api_key=server_api_key, host=host, port=port, protocol=protocol + ) + response = asyncio.run(client.upload_file(file_path)) + print( + f"Uploaded file to the server. Use the path: '{response}' to refer to the uploaded file in follow-up prompts." + ) + + +@app.command( + name="get-task", + help="Send a request to a LobsterX server to get the status of a task.", +) +def get_task( + task_id: str, + protocol: Annotated[ + Literal["http", "https"], + Option( + "--protocol", + "-t", + help="Protocol for the connection. Defaults to 'http'.", + ), + ] = "http", + host: Annotated[ + str, + Option( + "--bind", + "-b", + help="Host to bind the server to. Defaults to 0.0.0.0", + ), + ] = "0.0.0.0", + port: Annotated[ + int, + Option( + "--port", + "-p", + help="Port to bind the server to. Defaults to 8000", + ), + ] = 8000, + server_api_key: Annotated[ + str | None, + Option( + "--server-key", + help="API key to be used within the server to authorize requests. Reads from LOBSTERX_SERVER_KEY env variable if not provided.", + ), + ] = None, + config_file: Annotated[ + str | None, + Option( + "--config", + "-c", + help="Config file from which to read the LobsterX server configuration. Configured options have precedence over CLI.", + ), + ] = None, +) -> None: + if config_file is not None: + args = LobsterXApiConfig.load_from_config(config_file) + port = args.port or port + host = args.host or host + protocol = args.protocol or protocol + client = LobsterXClient( + api_key=server_api_key, host=host, port=port, protocol=protocol + ) + response = asyncio.run(client.get_task(task_id)) + if response.status.value in ("cancelled", "failed"): + rprint(f"[bold red]Task {task_id} was cancelled or produced an error[/]") + if response.error is not None: + rprint(f"[bold red]Error: {response.error}[/]") + elif response.status.value == "pending": + rprint(f"[bold cyan]Task {task_id} is still being executed[/]") + else: + final_output = ( + response.output[1] if response.output is not None else "No final output" + ) + report = ( + response.output[0] if response.output is not None else "No activity report" + ) + rprint( + Markdown( + f"## Final Outout\n\n{final_output}\n\n## Activity Report\n\n{report}" + ) + ) + + +@app.command( + name="cancel-task", + help="Send a request to a LobsterX server to cancel a task.", +) +def cancel_task( + task_id: str, + protocol: Annotated[ + Literal["http", "https"], + Option( + "--protocol", + "-t", + help="Protocol for the connection. Defaults to 'http'.", + ), + ] = "http", + host: Annotated[ + str, + Option( + "--bind", + "-b", + help="Host to bind the server to. Defaults to 0.0.0.0", + ), + ] = "0.0.0.0", + port: Annotated[ + int, + Option( + "--port", + "-p", + help="Port to bind the server to. Defaults to 8000", + ), + ] = 8000, + server_api_key: Annotated[ + str | None, + Option( + "--server-key", + help="API key to be used within the server to authorize requests. Reads from LOBSTERX_SERVER_KEY env variable if not provided.", + ), + ] = None, + config_file: Annotated[ + str | None, + Option( + "--config", + "-c", + help="Config file from which to read the LobsterX server configuration. Configured options have precedence over CLI.", + ), + ] = None, +) -> None: + if config_file is not None: + args = LobsterXApiConfig.load_from_config(config_file) + port = args.port or port + host = args.host or host + protocol = args.protocol or protocol + client = LobsterXClient( + api_key=server_api_key, host=host, port=port, protocol=protocol + ) + asyncio.run(client.cancel_task(task_id)) + print(f"Successfully cancelled task {task_id}.") diff --git a/packages/lobsterx/tests/api/__init__.py b/packages/lobsterx/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/lobsterx/tests/api/test_api.py b/packages/lobsterx/tests/api/test_api.py new file mode 100644 index 0000000..a9aed71 --- /dev/null +++ b/packages/lobsterx/tests/api/test_api.py @@ -0,0 +1,133 @@ +import asyncio +import os +from pathlib import Path +from secrets import token_urlsafe +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +from lobsterx.api.api import GetTaskResponse, create_api_app +from lobsterx.api.shared import TaskRequest, TaskResponse, UploadFileResponse +from lobsterx.constants import DATA_DIR + + +def test_api_file_uploads(tmp_path: Path) -> None: + with patch( + "lobsterx.api.api._download_file_to_agentfs", new_callable=AsyncMock + ) as mock_download: + mock_download.return_value = None + api_key = token_urlsafe(48) + app = create_api_app( + server_api_key=api_key, + allow_origins=[], + file_downloads_per_minute=None, + poll_tasks_per_minute=None, + delete_tasks_per_minute=None, + create_tasks_per_minute=None, + ) + client = TestClient(app) + (tmp_path / "test.txt").write_text("hello world") + with open(tmp_path / "test.txt", "rb") as f: + files = ("test.txt", f, "text/plain") + response = client.post( + "/files", + files={"file": files}, + headers={"Authorization": f"Bearer {api_key}"}, + ) + assert response.status_code == 200 + payload = response.json() + validated = UploadFileResponse.model_validate(payload) + assert validated.new_file_path == os.path.join(DATA_DIR, "test.txt") + mock_download.assert_awaited_once() + + +async def handle_prompt_mock(*args, **kwargs): + return ("hello", "world") + + +async def handle_prompt_mock_cancel(*args, **kwargs): + await asyncio.sleep(0.1) + return ("hello", "world") + + +@pytest.mark.asyncio +async def test_api_create_and_get_task() -> None: + with patch("lobsterx.api.api.handle_prompt", new_callable=AsyncMock) as mock_prompt: + mock_prompt.side_effect = handle_prompt_mock + api_key = token_urlsafe(48) + app = create_api_app( + server_api_key=api_key, + allow_origins=[], + file_downloads_per_minute=None, + poll_tasks_per_minute=None, + delete_tasks_per_minute=None, + create_tasks_per_minute=None, + ) + client = TestClient(app) + json = TaskRequest(prompt="hello world").model_dump() + response = client.post( + "/tasks", + json=json, + headers={"Authorization": f"Bearer {api_key}"}, + ) + assert response.status_code == 200 + payload = response.json() + validated = TaskResponse.model_validate(payload) + # is a UUID v4 + assert len(validated.task_id) == 36 + assert validated.task_id.count("-") == 4 + await asyncio.sleep(0.01) + response_get = client.get( + f"/tasks/{validated.task_id}", + headers={"Authorization": f"Bearer {api_key}"}, + ) + assert response_get.status_code == 200 + payload = response_get.json() + validated = GetTaskResponse.model_validate(payload) + assert validated.output == ("hello", "world") + + +@pytest.mark.asyncio +async def test_api_create_and_cancel_task() -> None: + with patch("lobsterx.api.api.handle_prompt", new_callable=AsyncMock) as mock_prompt: + mock_prompt.side_effect = handle_prompt_mock_cancel + api_key = token_urlsafe(48) + app = create_api_app( + server_api_key=api_key, + allow_origins=[], + file_downloads_per_minute=None, + poll_tasks_per_minute=None, + delete_tasks_per_minute=None, + create_tasks_per_minute=None, + ) + client = TestClient(app) + json = TaskRequest(prompt="hello world").model_dump() + response = client.post( + "/tasks", + json=json, + headers={"Authorization": f"Bearer {api_key}"}, + ) + assert response.status_code == 200 + payload = response.json() + validated = TaskResponse.model_validate(payload) + # is a UUID v4 + assert len(validated.task_id) == 36 + assert validated.task_id.count("-") == 4 + await asyncio.sleep(0.01) + response_delete = client.delete( + f"/tasks/{validated.task_id}", + headers={"Authorization": f"Bearer {api_key}"}, + ) + assert response_delete.status_code == 204 + await asyncio.sleep(0.03) + # try getting cancelled task (returns 404) + response_get = client.get( + f"/tasks/{validated.task_id}", + headers={"Authorization": f"Bearer {api_key}"}, + ) + assert response_get.status_code == 404 or ( + response.status_code == 200 + and GetTaskResponse.model_validate(response_get.json()).status.value + == "cancelled" + ) diff --git a/packages/lobsterx/tests/api/test_auth.py b/packages/lobsterx/tests/api/test_auth.py new file mode 100644 index 0000000..037fd9b --- /dev/null +++ b/packages/lobsterx/tests/api/test_auth.py @@ -0,0 +1,95 @@ +from secrets import token_urlsafe + +import pytest +from starlette.datastructures import Headers +from starlette.requests import Request +from starlette.responses import PlainTextResponse + +from lobsterx.api.auth import ( + DEFAULT_DISPLAY_NAME, + DEFAULT_IDENTITY, + AuthenticationError, + LobsterXAuthentication, + LobsterXUser, + on_auth_error, +) + + +def mock_request( + api_key: str, invalid_header: bool = False, no_header: bool = False +) -> Request: + scope = { + "type": "http", + "method": "GET", + "path": "/", + "headers": Headers({"Authorization": f"Bearer {api_key}"}).raw, + "query_string": b"", + } + if invalid_header: + scope["headers"] = Headers( + {"Authorization": f"Bearer {api_key} Bearer {api_key}"} + ).raw + elif no_header: + scope["headers"] = Headers({"Content-Type": "application/json"}).raw + return Request(scope) + + +def test_lobsterx_user() -> None: + user = LobsterXUser(authenticated=False) + assert not user.is_authenticated + assert user.display_name == DEFAULT_DISPLAY_NAME + assert user.identity == DEFAULT_IDENTITY + user1 = LobsterXUser(authenticated=True) + assert user1.is_authenticated + + +@pytest.mark.asyncio +async def test_lobsterx_auth_success() -> None: + api_key = token_urlsafe(32) + auth = LobsterXAuthentication(api_key) + request = mock_request(api_key) + creds = await auth.authenticate(request) + assert creds is not None + assert creds[0].scopes == ["http"] + assert creds[1].is_authenticated + assert creds[1].display_name == DEFAULT_DISPLAY_NAME + assert creds[1].identity == DEFAULT_IDENTITY + + +@pytest.mark.asyncio +async def test_lobsterx_auth_invalid_key() -> None: + api_key = token_urlsafe(32) + other_key = token_urlsafe(48) + auth = LobsterXAuthentication(api_key) + request = mock_request(other_key) + with pytest.raises(AuthenticationError, match="API key not authorized"): + await auth.authenticate(request) + + +@pytest.mark.asyncio +async def test_lobsterx_auth_no_header() -> None: + api_key = token_urlsafe(32) + auth = LobsterXAuthentication(api_key) + request = mock_request(api_key, no_header=True) + with pytest.raises(AuthenticationError, match="No authorization header in request"): + await auth.authenticate(request) + + +@pytest.mark.asyncio +async def test_lobsterx_auth_double_header() -> None: + api_key = token_urlsafe(32) + auth = LobsterXAuthentication(api_key) + request = mock_request(api_key, invalid_header=True) + with pytest.raises( + AuthenticationError, match="Should only provide one bearer token" + ): + await auth.authenticate(request) + + +def test_lobsterx_auth_on_error() -> None: + api_key = token_urlsafe(32) + request = mock_request(api_key, invalid_header=True) + resp = on_auth_error(request, AuthenticationError("An error occurred")) + assert isinstance(resp, PlainTextResponse) + assert resp.status_code == 401 + assert str(resp.body, encoding="utf-8") == "An error occurred" diff --git a/packages/lobsterx/tests/api/test_shared.py b/packages/lobsterx/tests/api/test_shared.py new file mode 100644 index 0000000..42b9254 --- /dev/null +++ b/packages/lobsterx/tests/api/test_shared.py @@ -0,0 +1,71 @@ +import json +from pathlib import Path +from secrets import token_urlsafe + +import pytest + +from lobsterx.api.api import validate_api_key +from lobsterx.api.shared import GetTaskResponse, LobsterXApiConfig +from lobsterx.api.task_manager import StatusEnum, TaskRepr + + +def test_lobsterx_api_config_correct_config() -> None: + conf = LobsterXApiConfig.load_from_config("config.api.json") + assert conf.allow_origins == [] + assert conf.create_tasks_per_minute == 60 + assert conf.delete_tasks_per_minute == 60 + assert conf.poll_tasks_per_minute == 300 + assert conf.file_downloads_per_minute == 300 + assert conf.port == 9000 + assert conf.host == "0.0.0.0" + assert conf.protocol == "http" + assert conf.server_api_key is None + + +def test_lobsterx_api_config_wrong(tmp_path: Path) -> None: + with open("config.api.json") as f: + data = json.load(f) + assert isinstance(data, dict) + data.pop("create_tasks_per_minute") + data["create_task_per_minute"] = 50 + new_cfg = tmp_path / "config.json" + new_cfg.write_text(json.dumps(data)) + with pytest.raises(TypeError): + LobsterXApiConfig.load_from_config(str(new_cfg)) + + +def test_lobsterx_api_config_to_args() -> None: + conf = LobsterXApiConfig.load_from_config("config.api.json") + args = conf.to_args() + assert "allow_origins" in args + assert "create_tasks_per_minute" in args + assert "delete_tasks_per_minute" in args + assert "poll_tasks_per_minute" in args + assert "file_downloads_per_minute" in args + assert "server_api_key" in args + assert "host" not in args + assert "port" not in args + assert "protocol" not in args + + +def test_get_task_response_from_task_repr() -> None: + repr = TaskRepr(status=StatusEnum.SUCCESS, output=("hello", "world"), error=None) + response = GetTaskResponse.from_dataclass(repr) + assert response.status.value == "success" + assert response.output == ("hello", "world") + assert response.error is None + + +def test_validate_api_key_correct() -> None: + valid_api_key = token_urlsafe(32) # always valid key + assert validate_api_key(valid_api_key) + + +def test_validate_api_key_too_short() -> None: + invalid_api_key = token_urlsafe(16) # always valid key, but less than 32 + assert not validate_api_key(invalid_api_key) + + +def test_validate_api_key_invalid() -> None: + invalid_api_key = "This?is-alitt5le=someYvdjwhuiabaf74q93$nfeooN)" # len > 32, but does not match regex + assert not validate_api_key(invalid_api_key) diff --git a/packages/lobsterx/tests/api/test_task_manager.py b/packages/lobsterx/tests/api/test_task_manager.py new file mode 100644 index 0000000..e2bc267 --- /dev/null +++ b/packages/lobsterx/tests/api/test_task_manager.py @@ -0,0 +1,78 @@ +import asyncio + +import pytest + +from lobsterx.api.task_manager import InMemoryTaskManager + + +async def hello_world() -> tuple[str, str]: + await asyncio.sleep(0.02) + return "hello", "world" + + +async def throws_error() -> tuple[str, str]: + await asyncio.sleep(0.02) + raise ValueError("An error occurred") + + +def test_task_manager_init() -> None: + manager = InMemoryTaskManager() + assert len(manager._tasks) == 0 + assert isinstance(manager._lock, asyncio.Lock) + + +@pytest.mark.asyncio +async def test_task_manager_add() -> None: + manager = InMemoryTaskManager() + task = asyncio.create_task(hello_world()) + task_id = await manager.add_task(task) + assert task_id in manager._tasks + await asyncio.sleep(0.03) + t = manager._tasks.get(task_id) + assert isinstance(t, asyncio.Task) + assert t.done() + result = t.result() + assert result == ("hello", "world") + + +@pytest.mark.asyncio +async def test_task_manager_check() -> None: + manager = InMemoryTaskManager() + task = asyncio.create_task(hello_world()) + task_id = await manager.add_task(task) + result = await manager.check_task(task_id) + assert result is not None + assert result.status.value == "pending" + await asyncio.sleep(0.03) + result_done = await manager.check_task(task_id) + assert result_done is not None + assert result_done.status.value == "success" + assert result_done.output == ("hello", "world") + assert result_done.error is None + assert task_id not in manager._tasks # tasks has been popped off of the dict + + +@pytest.mark.asyncio +async def test_task_manager_check_with_error() -> None: + manager = InMemoryTaskManager() + task = asyncio.create_task(throws_error()) + task_id = await manager.add_task(task) + result = await manager.check_task(task_id) + assert result is not None + assert result.status.value == "pending" + await asyncio.sleep(0.03) + result_done = await manager.check_task(task_id) + assert result_done is not None + assert result_done.status.value == "failed" + assert result_done.output is None + assert result_done.error == "An error occurred" + assert task_id not in manager._tasks # tasks has been popped off of the dict + + +@pytest.mark.asyncio +async def test_task_manager_cancel() -> None: + manager = InMemoryTaskManager() + task = asyncio.create_task(hello_world()) + task_id = await manager.add_task(task) + await manager.cancel_task(task_id) + assert task_id not in manager._tasks # tasks has been popped off of the dict diff --git a/packages/lobsterx/tests/test_cli.py b/packages/lobsterx/tests/test_cli.py index d7071b6..d04e0c8 100644 --- a/packages/lobsterx/tests/test_cli.py +++ b/packages/lobsterx/tests/test_cli.py @@ -2,9 +2,10 @@ import pytest from dotenv import dotenv_values -from lobsterx.cli import app from typer.testing import CliRunner +from lobsterx.cli import app + runner = CliRunner() @@ -21,6 +22,8 @@ def test_setup_command_defaults(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) "--telegram-token", "tok", "--no-interactive", + "--server-key", + "key", ], ) assert (tmp_path / ".env").is_file() @@ -31,6 +34,7 @@ def test_setup_command_defaults(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) assert data["LOBSTERX_LLM_API_KEY"] == "secret-key" assert data["LLAMA_CLOUD_API_KEY"] == "llama-cloud-key" assert data["TELEGRAM_BOT_TOKEN"] == "tok" + assert data["LOBSTERX_SERVER_KEY"] == "key" def test_setup_command_custom(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): @@ -49,6 +53,8 @@ def test_setup_command_custom(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): "llama-cloud-key", "--telegram-token", "tok", + "--server-key", + "key", "--no-interactive", ], ) @@ -60,3 +66,4 @@ def test_setup_command_custom(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): assert data["LOBSTERX_LLM_API_KEY"] == "secret-key" assert data["LLAMA_CLOUD_API_KEY"] == "llama-cloud-key" assert data["TELEGRAM_BOT_TOKEN"] == "tok" + assert data["LOBSTERX_SERVER_KEY"] == "key" diff --git a/pyproject.toml b/pyproject.toml index bc93d97..cec8e9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "workflows-acp" -version = "0.4.0-alpha" +version = "0.4.0-alpha.1" description = "LlamaIndex Agent Workflows over the ACP wire" readme = "README.md" requires-python = ">=3.11" diff --git a/src/workflows_acp/tools/filesystem.py b/src/workflows_acp/tools/filesystem.py index a24c301..72930a0 100644 --- a/src/workflows_acp/tools/filesystem.py +++ b/src/workflows_acp/tools/filesystem.py @@ -1,6 +1,6 @@ +import glob import os import re -import glob def describe_dir_content(directory: str) -> str: @@ -26,6 +26,7 @@ def describe_dir_content(directory: str) -> str: files.append(fullpath) else: directories.append(fullpath) + files = sorted(files) description += "FILES:\n- " + "\n- ".join(files) if not directories: description += "\nThis folder does not have any sub-folders" diff --git a/tests/tools/test_filesystem.py b/tests/tools/test_filesystem.py index a3c7b45..654a2be 100644 --- a/tests/tools/test_filesystem.py +++ b/tests/tools/test_filesystem.py @@ -1,13 +1,13 @@ -import pytest - from pathlib import Path + +import pytest from workflows_acp.tools.filesystem import ( describe_dir_content, - read_file, - grep_file_content, + edit_file, glob_paths, + grep_file_content, + read_file, write_file, - edit_file, ) @@ -15,7 +15,7 @@ def test_describe_dir_content() -> None: description = describe_dir_content("tests/testfiles") assert ( description - == "Content of tests/testfiles\nFILES:\n- tests/testfiles/file1.txt\n- tests/testfiles/agent_config.yaml\n- tests/testfiles/file2.md\nSUBFOLDERS:\n- tests/testfiles/last" + == "Content of tests/testfiles\nFILES:\n- tests/testfiles/agent_config.yaml\n- tests/testfiles/file1.txt\n- tests/testfiles/file2.md\nSUBFOLDERS:\n- tests/testfiles/last" ) description = describe_dir_content("tests/testfile") assert description == "No such directory: tests/testfile" diff --git a/uv.lock b/uv.lock index 35b34c6..19616c5 100644 --- a/uv.lock +++ b/uv.lock @@ -45,6 +45,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -417,6 +426,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] +[[package]] +name = "fastapi" +version = "0.129.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" }, +] + +[[package]] +name = "fastapi-throttle" +version = "0.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastapi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/ef/b71c86ced9cabc075ed4629178960ccdaf55b56f37a5392c574b2fa4ad35/fastapi_throttle-0.1.8.tar.gz", hash = "sha256:b0867be26bf3335cae0ecb7069c5cb7d246e14dbbd51329050af7043435370ce", size = 7970, upload-time = "2025-08-26T13:13:28.554Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/81/972fa6f3fd2b45fa11fd7269aa24a242dd51c093ed45937f201224cc7e85/fastapi_throttle-0.1.8-py3-none-any.whl", hash = "sha256:ba04991eae1795b13b1d3a514a6967ba4588218dc92375e4c45e12cdebe160e2", size = 8649, upload-time = "2025-08-26T13:13:27.78Z" }, +] + [[package]] name = "filelock" version = "3.20.1" @@ -843,8 +880,12 @@ source = { editable = "packages/lobsterx" } dependencies = [ { name = "aiofiles" }, { name = "diskcache" }, + { name = "fastapi" }, + { name = "fastapi-throttle" }, + { name = "httpx" }, { name = "llama-cloud" }, { name = "python-dotenv" }, + { name = "python-multipart" }, { name = "python-telegram-bot" }, { name = "random-name" }, { name = "workflows-acp" }, @@ -863,8 +904,12 @@ dev = [ requires-dist = [ { name = "aiofiles", specifier = ">=25.1.0" }, { name = "diskcache", specifier = ">=5.6.3" }, + { name = "fastapi", specifier = ">=0.129.0" }, + { name = "fastapi-throttle", specifier = ">=0.1.8" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "llama-cloud", specifier = ">=1.2.0,<1.3" }, { name = "python-dotenv", specifier = ">=1.2.1" }, + { name = "python-multipart", specifier = ">=0.0.21" }, { name = "python-telegram-bot", specifier = ">=22.6" }, { name = "random-name", specifier = ">=0.1.1" }, { name = "workflows-acp", editable = "." }, @@ -1985,7 +2030,7 @@ wheels = [ [[package]] name = "workflows-acp" -version = "0.4.0a0" +version = "0.4.0a1" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" },