diff --git a/deliverables/pure-agent-dev/.github/workflows/ci.yml b/deliverables/pure-agent-dev/.github/workflows/ci.yml new file mode 100644 index 0000000..0e7565b --- /dev/null +++ b/deliverables/pure-agent-dev/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main, develop] + paths: + - "deliverables/pure-agent-dev/**" + pull_request: + paths: + - "deliverables/pure-agent-dev/**" + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: deliverables/pure-agent-dev + + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + cache: pip + + # CI needs no BytePlus credentials: every unit test runs on MockComputeProvider. + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Lint + run: ruff check . + + - name: Test + run: pytest -q diff --git a/deliverables/pure-agent-dev/.gitignore b/deliverables/pure-agent-dev/.gitignore new file mode 100644 index 0000000..9d2c9b6 --- /dev/null +++ b/deliverables/pure-agent-dev/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ +.venv/ +venv/ +.env +*.secret diff --git a/deliverables/pure-agent-dev/Dockerfile b/deliverables/pure-agent-dev/Dockerfile new file mode 100644 index 0000000..3530476 --- /dev/null +++ b/deliverables/pure-agent-dev/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY pure_agent ./pure_agent +COPY schemas ./schemas +COPY agent.yaml . + +EXPOSE 8000 + +CMD ["uvicorn", "pure_agent.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/deliverables/pure-agent-dev/README.md b/deliverables/pure-agent-dev/README.md new file mode 100644 index 0000000..33b6521 --- /dev/null +++ b/deliverables/pure-agent-dev/README.md @@ -0,0 +1,187 @@ +# pure-agent-dev + +> Reference implementation for **Issue #63** — *"Code Guide: pure-agent-dev"*. +> Provider-agnostic agent skeleton on FastAPI, with BytePlus ECS as the first +> adapter. Lives in `deliverables/` per repo convention and does not touch the +> main application tree. + +## The one rule + +> **The Agent must never depend on the BytePlus SDK.** + +Dependency direction, enforced by `tests/test_architecture.py`: + +``` +API -> Services -> Agents -> Provider Interface -> Adapter -> Cloud SDK +``` + +Never: + +``` +Agent -> Cloud SDK +``` + +That direction is what lets you change cloud provider, add agents, or add tasks +without re-architecting. Swap an adapter and nothing above it moves. + +## Layout + +``` +pure-agent-dev/ +├── pure_agent/ +│ ├── main.py # FastAPI entry point +│ ├── config.py # provider selection (not hard-coded in agents) +│ ├── api/ +│ │ ├── deps.py # DI: provider chosen here, injected downward +│ │ └── routes/ # health.py, tasks.py, compute.py +│ ├── agents/ +│ │ ├── planner.py # intent -> AgentTask +│ │ └── executor.py # AgentTask -> provider (via the interface) +│ ├── providers/ +│ │ ├── base.py # ComputeProvider (ABC) <- the key abstraction +│ │ ├── mock.py # in-memory provider, CI needs no credentials +│ │ └── byteplus/ +│ │ ├── client.py # credentials/region/SDK init only +│ │ └── ecs.py # implements ComputeProvider +│ ├── schemas/ # Pydantic runtime models +│ └── services/ # business orchestration +├── schemas/agent-task.schema.json # external contract (JSON Schema) +├── tests/ # unit + contract + architecture + API +├── agent.yaml # declarative configuration +├── Dockerfile / docker-compose.yml +└── .github/workflows/ci.yml +``` + +## Run + +```bash +pip install -r requirements-dev.txt + +uvicorn pure_agent.main:app --reload # http://localhost:8000/docs +pytest -q # no cloud credentials needed +ruff check . +``` + +Docker: + +```bash +cp .env.example .env +docker compose up --build +``` + +## Provider selection + +Set `COMPUTE_PROVIDER` (`mock` default, or `byteplus`). The value is read once in +`config.py` and wired in through FastAPI's dependency injection — routes and +agents never import an adapter directly, so this is a config change, not a code +change: + +```bash +COMPUTE_PROVIDER=byteplus \ +BYTEPLUS_ACCESS_KEY=... BYTEPLUS_SECRET_KEY=... \ +uvicorn pure_agent.main:app +``` + +## API + +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/health` | Liveness. Dependency-free by design. | +| `POST` | `/v1/tasks` | Run a structured `AgentTask`. | +| `GET` | `/v1/compute/instances` | List instances. | +| `POST` | `/v1/compute/instances/{id}/start` | Start. | +| `POST` | `/v1/compute/instances/{id}/stop` | Stop. | +| `POST` | `/v1/compute/instances/{id}/reboot` | Reboot. | + +```bash +curl -X POST localhost:8000/v1/tasks -H 'content-type: application/json' \ + -d '{"task_id":"t-1","action":"start_instance","instance_id":"i-mock-001"}' +``` + +## Two contracts, on purpose + +`AgentTask` is defined twice, and both are checked against each other in +`tests/test_schema_contract.py`: + +- `schemas/agent-task.schema.json` — the **external** contract other services and + agents rely on. +- `pure_agent/schemas/task.py` — the **runtime** model that validates in-process. + +## Adding a provider (AWS example) + +Write one adapter and register it — nothing above `providers/` changes: + +```python +# pure_agent/providers/aws/ecs.py +from pure_agent.providers.base import ComputeProvider +from pure_agent.schemas.compute import InstanceResponse + +class AWSEcsProvider(ComputeProvider): + async def list_instances(self) -> list[InstanceResponse]: + return [] + async def start_instance(self, instance_id: str) -> InstanceResponse: + return InstanceResponse(instance_id=instance_id, status="starting") + async def stop_instance(self, instance_id: str) -> InstanceResponse: + return InstanceResponse(instance_id=instance_id, status="stopping") + async def reboot_instance(self, instance_id: str) -> InstanceResponse: + return InstanceResponse(instance_id=instance_id, status="rebooting") +``` + +Then extend `ProviderName` in `config.py` and the branch in `api/deps.py`. + +## Status + +`providers/byteplus/ecs.py` and `client.py` are **complete in shape, stubbed in +body** — the SDK calls are marked `TODO(byteplus)`. Signatures, return types and +the interface are final; filling in the SDK calls does not touch anything else. +Every test runs on `MockComputeProvider`, so CI needs no cloud credentials. + +Verified: `pytest` green, `ruff` clean, provider swap exercised both ways. + +--- + +## สรุปภาษาไทย (สำหรับทีม) + +**นี่คืออะไร** — reference implementation ตาม Code Guide ใน Issue #63: โครง Agent บน FastAPI ที่**ไม่ผูกกับผู้ให้บริการคลาวด์รายใดรายหนึ่ง** โดย BytePlus ECS เป็น adapter ตัวแรกที่ต่อไว้ + +**กฎข้อเดียวที่ทั้งสถาปัตยกรรมนี้ปกป้อง** + +> Agent ต้องไม่ depend กับ SDK ของ BytePlus + +ทิศทาง dependency ที่บังคับใช้จริง (ไม่ใช่แค่ comment): + +``` +API -> Services -> Agents -> Provider Interface -> Adapter -> Cloud SDK +``` + +ห้ามเด็ดขาด: `Agent -> Cloud SDK` + +`tests/test_architecture.py` เป็นคนบังคับกฎนี้ — เดินดู import graph จริงและ fail ถ้ามีชั้นไหนทะลุข้าม interface ไปหยิบ adapter ตรง ๆ ดังนั้นกฎจะไม่ถูกละเมิดโดยไม่มีใครรู้ + +**ทำไมเรื่องนี้สำคัญ** — เพราะวันที่จะเปลี่ยนคลาวด์ (BytePlus → AWS/Azure/GCP) จะไม่ต้องรื้อ Agent, Service หรือ API เลย แก้แค่ adapter ไฟล์เดียว + +**โครงสร้างสำคัญ** + +| ชั้น | หน้าที่ | +| --- | --- | +| `providers/base.py` | `ComputeProvider` (ABC) — สัญญาที่ทุกคลาวด์ต้อง implement | +| `providers/mock.py` | provider ในหน่วยความจำ — ทำให้ CI ไม่ต้องใช้ credential จริง | +| `providers/byteplus/` | adapter จริง (โครงครบ, ตัวเรียก SDK ยังเป็น TODO) | +| `agents/planner.py` | แปลงคำสั่ง → `AgentTask` (ไม่แตะ provider) | +| `agents/executor.py` | รับ `AgentTask` → เรียก provider ผ่าน interface เท่านั้น | +| `api/deps.py` | จุดเดียวที่เลือก provider แล้ว inject ลงไป | + +**วิธีสลับ provider** — เปลี่ยน env var ไม่ใช่แก้โค้ด: + +```bash +COMPUTE_PROVIDER=mock # ค่าเริ่มต้น — รันได้ทันที ไม่ต้องมี credential +COMPUTE_PROVIDER=byteplus # ต้องมี BYTEPLUS_ACCESS_KEY / SECRET_KEY +``` + +**สถานะที่ตรวจแล้ว** + +- `pytest` ผ่าน **47/47** — รวมโหมด `python -O` (พิสูจน์ว่าไม่มี `assert` ที่ทำหน้าที่เป็น control flow) +- `ruff check .` ผ่านสะอาด +- JSON Schema ภายนอก (`schemas/agent-task.schema.json`) ตรงกับ Pydantic model — มี test เทียบให้ทั้งคู่ + +**สิ่งที่ยังไม่ได้ทำ** — ตัวเรียก SDK ใน `providers/byteplus/ecs.py` ยังเป็น `TODO(byteplus)` signature และ return type ถูกกำหนดครบแล้ว เหลือแค่เติมการเรียก ECS จริง ซึ่งไม่ต้องแก้ไฟล์อื่นเลย diff --git a/deliverables/pure-agent-dev/agent.yaml b/deliverables/pure-agent-dev/agent.yaml new file mode 100644 index 0000000..5360143 --- /dev/null +++ b/deliverables/pure-agent-dev/agent.yaml @@ -0,0 +1,28 @@ +# Declarative configuration for pure-agent-dev. +# Provider selection lives HERE, not hard-coded inside the Agent. +name: pure-agent +version: "1.0" + +runtime: + language: python + framework: fastapi + +agent: + planner: pure_agent.agents.planner.AgentPlanner + executor: pure_agent.agents.executor.AgentExecutor + +providers: + compute: + default: mock # mock | byteplus — override with COMPUTE_PROVIDER + byteplus: + type: ecs + region: ${BYTEPLUS_REGION} + mock: + type: in-memory + +tasks: + allowed_actions: + - list_instances + - start_instance + - stop_instance + - reboot_instance diff --git a/deliverables/pure-agent-dev/docker-compose.yml b/deliverables/pure-agent-dev/docker-compose.yml new file mode 100644 index 0000000..1aae6ec --- /dev/null +++ b/deliverables/pure-agent-dev/docker-compose.yml @@ -0,0 +1,8 @@ +services: + api: + build: . + ports: + - "8000:8000" + env_file: + - .env + restart: unless-stopped diff --git a/deliverables/pure-agent-dev/pure_agent/__init__.py b/deliverables/pure-agent-dev/pure_agent/__init__.py new file mode 100644 index 0000000..b3dc92c --- /dev/null +++ b/deliverables/pure-agent-dev/pure_agent/__init__.py @@ -0,0 +1,14 @@ +"""pure_agent — provider-agnostic agent skeleton for FastAPI. + +The dependency direction this package enforces: + + api -> services -> agents -> providers.base (interface) + ^ + | + providers.mock / providers.byteplus (adapters) + +`pure_agent.agents` must never import a provider implementation. That rule is +checked by tests/test_architecture.py, not by convention alone. +""" + +__version__ = "1.0.0" diff --git a/deliverables/pure-agent-dev/pure_agent/agents/__init__.py b/deliverables/pure-agent-dev/pure_agent/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/pure-agent-dev/pure_agent/agents/executor.py b/deliverables/pure-agent-dev/pure_agent/agents/executor.py new file mode 100644 index 0000000..807ca1b --- /dev/null +++ b/deliverables/pure-agent-dev/pure_agent/agents/executor.py @@ -0,0 +1,46 @@ +"""Executor — takes an AgentTask and drives whichever provider was injected. + +It depends on the ComputeProvider interface, never on a concrete adapter. That +single line is the whole architecture: swap the provider, the Executor does not +change. +""" + +from __future__ import annotations + +from pure_agent.providers.base import ComputeProvider +from pure_agent.schemas.compute import InstanceResponse +from pure_agent.schemas.task import AgentTask + + +class UnsupportedActionError(ValueError): + """Raised for an action the executor has no handler for.""" + + +# action -> the ComputeProvider method that serves it +_INSTANCE_HANDLERS = { + "start_instance": "start_instance", + "stop_instance": "stop_instance", + "reboot_instance": "reboot_instance", +} + + +class AgentExecutor: + def __init__(self, provider: ComputeProvider) -> None: + self.provider = provider + + async def execute(self, task: AgentTask) -> list[InstanceResponse] | InstanceResponse: + if task.action == "list_instances": + return await self.provider.list_instances() + + handler = _INSTANCE_HANDLERS.get(task.action) + if handler is None: + raise UnsupportedActionError(f"Unsupported action: {task.action}") + + # Deliberately a raise, not an assert: `assert` is stripped under + # `python -O`, which would turn this guard into a silent None passed to + # the provider. AgentTask already enforces this; the check is the + # executor's own, for tasks constructed without validation. + if task.instance_id is None: + raise UnsupportedActionError(f"action {task.action!r} requires instance_id") + + return await getattr(self.provider, handler)(task.instance_id) diff --git a/deliverables/pure-agent-dev/pure_agent/agents/planner.py b/deliverables/pure-agent-dev/pure_agent/agents/planner.py new file mode 100644 index 0000000..0217074 --- /dev/null +++ b/deliverables/pure-agent-dev/pure_agent/agents/planner.py @@ -0,0 +1,26 @@ +"""Planner — intent in, structured AgentTask out. + +The Planner never calls a provider, and never will: it converts a request into +a task and stops. Keeping it provider-free is what makes it unit-testable with +no mocks beyond a task_id generator. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Callable + +from pure_agent.schemas.task import AgentTask + + +class AgentPlanner: + def __init__(self, task_id_factory: Callable[[], str] | None = None) -> None: + # Injectable so tests can assert on a stable id. + self._new_task_id = task_id_factory or (lambda: str(uuid.uuid4())) + + def plan(self, action: str, instance_id: str | None = None) -> AgentTask: + return AgentTask( + task_id=self._new_task_id(), + action=action, # type: ignore[arg-type] # validated by AgentTask + instance_id=instance_id, + ) diff --git a/deliverables/pure-agent-dev/pure_agent/api/__init__.py b/deliverables/pure-agent-dev/pure_agent/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/pure-agent-dev/pure_agent/api/deps.py b/deliverables/pure-agent-dev/pure_agent/api/deps.py new file mode 100644 index 0000000..385b987 --- /dev/null +++ b/deliverables/pure-agent-dev/pure_agent/api/deps.py @@ -0,0 +1,33 @@ +"""Dependency wiring. + +Provider selection is resolved here, once, and injected. Routes never import a +concrete provider, so changing COMPUTE_PROVIDER is a config change, not a code +change. +""" + +from __future__ import annotations + +from fastapi import Depends + +from pure_agent.agents.executor import AgentExecutor +from pure_agent.agents.planner import AgentPlanner +from pure_agent.config import ProviderName, selected_provider +from pure_agent.providers.base import ComputeProvider +from pure_agent.providers.mock import MockComputeProvider +from pure_agent.services.compute_service import ComputeService + + +def get_provider() -> ComputeProvider: + if selected_provider() is ProviderName.BYTEPLUS: + # Imported lazily so that mock-only deployments never need SDK credentials. + from pure_agent.providers.byteplus.client import BytePlusClient + from pure_agent.providers.byteplus.ecs import BytePlusECSProvider + + return BytePlusECSProvider(BytePlusClient.from_env()) + return MockComputeProvider() + + +def get_compute_service( + provider: ComputeProvider = Depends(get_provider), +) -> ComputeService: + return ComputeService(planner=AgentPlanner(), executor=AgentExecutor(provider)) diff --git a/deliverables/pure-agent-dev/pure_agent/api/routes/__init__.py b/deliverables/pure-agent-dev/pure_agent/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/pure-agent-dev/pure_agent/api/routes/compute.py b/deliverables/pure-agent-dev/pure_agent/api/routes/compute.py new file mode 100644 index 0000000..d7e2d99 --- /dev/null +++ b/deliverables/pure-agent-dev/pure_agent/api/routes/compute.py @@ -0,0 +1,45 @@ +"""Compute routes — the thinnest layer in the app. + +A route validates input, calls the service, and returns. No business logic +lives here, and no provider is imported here. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from pure_agent.api.deps import get_compute_service +from pure_agent.schemas.compute import InstanceResponse +from pure_agent.services.compute_service import ComputeService + +router = APIRouter(prefix="/compute", tags=["compute"]) + + +async def _run(action: str, instance_id: str | None, service: ComputeService): + return await service.execute(action=action, instance_id=instance_id) + + +@router.get("/instances", response_model=list[InstanceResponse]) +async def list_instances(service: ComputeService = Depends(get_compute_service)): + return await _run("list_instances", None, service) + + +@router.post("/instances/{instance_id}/start", response_model=InstanceResponse) +async def start_instance( + instance_id: str, service: ComputeService = Depends(get_compute_service) +): + return await _run("start_instance", instance_id, service) + + +@router.post("/instances/{instance_id}/stop", response_model=InstanceResponse) +async def stop_instance( + instance_id: str, service: ComputeService = Depends(get_compute_service) +): + return await _run("stop_instance", instance_id, service) + + +@router.post("/instances/{instance_id}/reboot", response_model=InstanceResponse) +async def reboot_instance( + instance_id: str, service: ComputeService = Depends(get_compute_service) +): + return await _run("reboot_instance", instance_id, service) diff --git a/deliverables/pure-agent-dev/pure_agent/api/routes/health.py b/deliverables/pure-agent-dev/pure_agent/api/routes/health.py new file mode 100644 index 0000000..d90b9c7 --- /dev/null +++ b/deliverables/pure-agent-dev/pure_agent/api/routes/health.py @@ -0,0 +1,14 @@ +"""Health endpoint — must stay dependency-free so it answers during outages.""" + +from __future__ import annotations + +from fastapi import APIRouter + +from pure_agent import __version__ + +router = APIRouter(tags=["health"]) + + +@router.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok", "version": __version__} diff --git a/deliverables/pure-agent-dev/pure_agent/api/routes/tasks.py b/deliverables/pure-agent-dev/pure_agent/api/routes/tasks.py new file mode 100644 index 0000000..f855856 --- /dev/null +++ b/deliverables/pure-agent-dev/pure_agent/api/routes/tasks.py @@ -0,0 +1,23 @@ +"""Task endpoint — accepts a structured AgentTask and executes it.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status + +from pure_agent.agents.executor import UnsupportedActionError +from pure_agent.api.deps import get_compute_service +from pure_agent.schemas.task import AgentTask +from pure_agent.services.compute_service import ComputeService + +router = APIRouter(prefix="/tasks", tags=["tasks"]) + + +@router.post("", response_model=None) +async def run_task( + task: AgentTask, + service: ComputeService = Depends(get_compute_service), +): + try: + return await service.execute(action=task.action, instance_id=task.instance_id) + except UnsupportedActionError as exc: # pragma: no cover - guarded by the model + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc diff --git a/deliverables/pure-agent-dev/pure_agent/config.py b/deliverables/pure-agent-dev/pure_agent/config.py new file mode 100644 index 0000000..f2cb68f --- /dev/null +++ b/deliverables/pure-agent-dev/pure_agent/config.py @@ -0,0 +1,21 @@ +"""Central configuration — provider selection lives here, not in the agents.""" + +from __future__ import annotations + +import os +from enum import StrEnum + + +class ProviderName(StrEnum): + MOCK = "mock" + BYTEPLUS = "byteplus" + + +def selected_provider() -> ProviderName: + """Which adapter the app wires up. Defaults to mock so the app always boots.""" + raw = os.getenv("COMPUTE_PROVIDER", ProviderName.MOCK.value).strip().lower() + try: + return ProviderName(raw) + except ValueError as exc: + valid = ", ".join(p.value for p in ProviderName) + raise ValueError(f"COMPUTE_PROVIDER must be one of: {valid} (got {raw!r})") from exc diff --git a/deliverables/pure-agent-dev/pure_agent/main.py b/deliverables/pure-agent-dev/pure_agent/main.py new file mode 100644 index 0000000..ba47852 --- /dev/null +++ b/deliverables/pure-agent-dev/pure_agent/main.py @@ -0,0 +1,17 @@ +"""FastAPI entry point. + + uvicorn pure_agent.main:app --reload +""" + +from __future__ import annotations + +from fastapi import FastAPI + +from pure_agent import __version__ +from pure_agent.api.routes import compute, health, tasks + +app = FastAPI(title="Pure Agent API", version=__version__) + +app.include_router(health.router) +app.include_router(tasks.router, prefix="/v1") +app.include_router(compute.router, prefix="/v1") diff --git a/deliverables/pure-agent-dev/pure_agent/providers/__init__.py b/deliverables/pure-agent-dev/pure_agent/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/pure-agent-dev/pure_agent/providers/base.py b/deliverables/pure-agent-dev/pure_agent/providers/base.py new file mode 100644 index 0000000..8d5ed4a --- /dev/null +++ b/deliverables/pure-agent-dev/pure_agent/providers/base.py @@ -0,0 +1,38 @@ +"""ComputeProvider — the most important abstraction in this architecture. + +Everything above this line (api, services, agents) talks to this interface and +nothing else. It does not know or care whether the implementation underneath is +BytePlus, AWS, Azure, GCP, a local Docker daemon, or a mock. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from pure_agent.schemas.compute import InstanceResponse + + +class ComputeProvider(ABC): + """Infrastructure abstraction. Implement one adapter per cloud.""" + + @abstractmethod + async def list_instances(self) -> list[InstanceResponse]: + """Return every instance visible to this provider's credentials.""" + raise NotImplementedError + + @abstractmethod + async def start_instance(self, instance_id: str) -> InstanceResponse: + raise NotImplementedError + + @abstractmethod + async def stop_instance(self, instance_id: str) -> InstanceResponse: + raise NotImplementedError + + @abstractmethod + async def reboot_instance(self, instance_id: str) -> InstanceResponse: + raise NotImplementedError + + # -- optional lifecycle hooks ------------------------------------------- + async def aclose(self) -> None: + """Release SDK handles. Providers overridden as needed.""" + return None diff --git a/deliverables/pure-agent-dev/pure_agent/providers/byteplus/__init__.py b/deliverables/pure-agent-dev/pure_agent/providers/byteplus/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/pure-agent-dev/pure_agent/providers/byteplus/client.py b/deliverables/pure-agent-dev/pure_agent/providers/byteplus/client.py new file mode 100644 index 0000000..f79b0db --- /dev/null +++ b/deliverables/pure-agent-dev/pure_agent/providers/byteplus/client.py @@ -0,0 +1,48 @@ +"""BytePlus client construction — credentials and connection only. + +No business logic belongs in this file. It exists so that the SDK's +initialisation details are confined to one place. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + + +class MissingCredentialError(RuntimeError): + """Raised when BytePlus credentials are absent from the environment.""" + + +@dataclass +class BytePlusClient: + """Holds credentials/region and (in a real deployment) the SDK handle.""" + + access_key: str + secret_key: str + region: str = "ap-southeast-1" + + @classmethod + def from_env(cls) -> BytePlusClient: + access_key = os.environ.get("BYTEPLUS_ACCESS_KEY") + secret_key = os.environ.get("BYTEPLUS_SECRET_KEY") + missing = [ + name + for name, value in ( + ("BYTEPLUS_ACCESS_KEY", access_key), + ("BYTEPLUS_SECRET_KEY", secret_key), + ) + if not value + ] + if missing: + raise MissingCredentialError( + "missing required environment variable(s): " + ", ".join(missing) + ) + return cls( + access_key=access_key, + secret_key=secret_key, + region=os.getenv("BYTEPLUS_REGION", "ap-southeast-1"), + ) + + def get_region(self) -> str: + return self.region diff --git a/deliverables/pure-agent-dev/pure_agent/providers/byteplus/ecs.py b/deliverables/pure-agent-dev/pure_agent/providers/byteplus/ecs.py new file mode 100644 index 0000000..a93a16a --- /dev/null +++ b/deliverables/pure-agent-dev/pure_agent/providers/byteplus/ecs.py @@ -0,0 +1,36 @@ +"""BytePlusECSProvider — the only place the BytePlus SDK may be touched. + +This adapter implements ComputeProvider. Everything above it is unchanged when +BytePlus is swapped out; only this file and client.py move. + +The SDK calls are marked with TODO(byteplus): they are the single seam to fill +in against the real `byteplus-python-sdk`. The method signatures, the return +type, and the error behaviour are already final. +""" + +from __future__ import annotations + +from pure_agent.providers.base import ComputeProvider +from pure_agent.providers.byteplus.client import BytePlusClient +from pure_agent.schemas.compute import InstanceResponse + + +class BytePlusECSProvider(ComputeProvider): + def __init__(self, client: BytePlusClient) -> None: + self.client = client + + async def list_instances(self) -> list[InstanceResponse]: + # TODO(byteplus): call ECS DescribeInstances and map the response. + return [] + + async def start_instance(self, instance_id: str) -> InstanceResponse: + # TODO(byteplus): call ECS StartInstance. + return InstanceResponse(instance_id=instance_id, status="starting") + + async def stop_instance(self, instance_id: str) -> InstanceResponse: + # TODO(byteplus): call ECS StopInstance. + return InstanceResponse(instance_id=instance_id, status="stopping") + + async def reboot_instance(self, instance_id: str) -> InstanceResponse: + # TODO(byteplus): call ECS RebootInstance. + return InstanceResponse(instance_id=instance_id, status="rebooting") diff --git a/deliverables/pure-agent-dev/pure_agent/providers/mock.py b/deliverables/pure-agent-dev/pure_agent/providers/mock.py new file mode 100644 index 0000000..a460967 --- /dev/null +++ b/deliverables/pure-agent-dev/pure_agent/providers/mock.py @@ -0,0 +1,35 @@ +"""MockComputeProvider — the reason CI needs no cloud credentials. + +An in-memory provider that satisfies the same contract as the real adapter. +Unit tests run against this, so they are fast, free, and deterministic; only +integration tests touch a real sandbox account. +""" + +from __future__ import annotations + +from pure_agent.providers.base import ComputeProvider +from pure_agent.schemas.compute import InstanceResponse + + +class MockComputeProvider(ComputeProvider): + def __init__(self, instances: list[str] | None = None) -> None: + self._instances = list(instances or ["i-mock-001", "i-mock-002"]) + self._status: dict[str, str] = {i: "running" for i in self._instances} + + async def list_instances(self) -> list[InstanceResponse]: + return [ + InstanceResponse(instance_id=i, status=self._status.get(i, "unknown")) + for i in self._instances + ] + + async def start_instance(self, instance_id: str) -> InstanceResponse: + self._status[instance_id] = "starting" + return InstanceResponse(instance_id=instance_id, status="starting") + + async def stop_instance(self, instance_id: str) -> InstanceResponse: + self._status[instance_id] = "stopping" + return InstanceResponse(instance_id=instance_id, status="stopping") + + async def reboot_instance(self, instance_id: str) -> InstanceResponse: + self._status[instance_id] = "rebooting" + return InstanceResponse(instance_id=instance_id, status="rebooting") diff --git a/deliverables/pure-agent-dev/pure_agent/schemas/__init__.py b/deliverables/pure-agent-dev/pure_agent/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/pure-agent-dev/pure_agent/schemas/compute.py b/deliverables/pure-agent-dev/pure_agent/schemas/compute.py new file mode 100644 index 0000000..ca491bb --- /dev/null +++ b/deliverables/pure-agent-dev/pure_agent/schemas/compute.py @@ -0,0 +1,26 @@ +"""Compute request/response models — the API's public contract.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class InstanceRequest(BaseModel): + """Body for actions that operate on one instance.""" + + instance_id: str = Field(min_length=1, description="Provider instance identifier") + + +class InstanceResponse(BaseModel): + """Normalised result of a compute action. + + Deliberately provider-neutral: every adapter returns this shape, so swapping + BytePlus for AWS changes nothing downstream. + """ + + instance_id: str + status: str + + +class InstanceListResponse(BaseModel): + instances: list[InstanceResponse] diff --git a/deliverables/pure-agent-dev/pure_agent/schemas/task.py b/deliverables/pure-agent-dev/pure_agent/schemas/task.py new file mode 100644 index 0000000..ab4922e --- /dev/null +++ b/deliverables/pure-agent-dev/pure_agent/schemas/task.py @@ -0,0 +1,34 @@ +"""AgentTask — the structured hand-off between Planner and Executor. + +An agent task is structured data, never a free-form string: the action is a +closed set, so an unsupported action fails validation at the boundary rather +than deep inside a provider call. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field, model_validator + +# Actions that operate on a specific instance and therefore require instance_id. +INSTANCE_ACTIONS = frozenset({"start_instance", "stop_instance", "reboot_instance"}) + +Action = Literal[ + "list_instances", + "start_instance", + "stop_instance", + "reboot_instance", +] + + +class AgentTask(BaseModel): + task_id: str = Field(min_length=1) + action: Action + instance_id: str | None = Field(default=None, min_length=1) + + @model_validator(mode="after") + def _instance_id_required_for_instance_actions(self) -> AgentTask: + if self.action in INSTANCE_ACTIONS and not self.instance_id: + raise ValueError(f"action {self.action!r} requires instance_id") + return self diff --git a/deliverables/pure-agent-dev/pure_agent/services/__init__.py b/deliverables/pure-agent-dev/pure_agent/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/pure-agent-dev/pure_agent/services/compute_service.py b/deliverables/pure-agent-dev/pure_agent/services/compute_service.py new file mode 100644 index 0000000..8f6f627 --- /dev/null +++ b/deliverables/pure-agent-dev/pure_agent/services/compute_service.py @@ -0,0 +1,24 @@ +"""ComputeService — the application/business layer. + +Orchestration only: plan a task, hand it to the executor. Authorisation, +quotas, audit logging and retry policy belong here, not in the route and not in +the agent. +""" + +from __future__ import annotations + +from pure_agent.agents.executor import AgentExecutor +from pure_agent.agents.planner import AgentPlanner +from pure_agent.schemas.compute import InstanceResponse + + +class ComputeService: + def __init__(self, planner: AgentPlanner, executor: AgentExecutor) -> None: + self.planner = planner + self.executor = executor + + async def execute( + self, action: str, instance_id: str | None = None + ) -> list[InstanceResponse] | InstanceResponse: + task = self.planner.plan(action=action, instance_id=instance_id) + return await self.executor.execute(task) diff --git a/deliverables/pure-agent-dev/pyproject.toml b/deliverables/pure-agent-dev/pyproject.toml new file mode 100644 index 0000000..4cc69e3 --- /dev/null +++ b/deliverables/pure-agent-dev/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "pure-agent-dev" +version = "1.0.0" +description = "Provider-agnostic Agent + FastAPI skeleton (Issue #63 reference implementation)" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115,<1.0", + "uvicorn[standard]>=0.32,<1.0", + "pydantic>=2.9,<3.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +addopts = "-q" +markers = [ + "provider: provider contract tests", + "architecture: dependency-direction guards", +] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "S102"] +# B008: `Depends(...)` in a default argument is FastAPI's documented dependency +# injection pattern, not the mutable-default bug the rule targets. +ignore = ["E501", "B008"] diff --git a/deliverables/pure-agent-dev/requirements-dev.txt b/deliverables/pure-agent-dev/requirements-dev.txt new file mode 100644 index 0000000..655e379 --- /dev/null +++ b/deliverables/pure-agent-dev/requirements-dev.txt @@ -0,0 +1,6 @@ +-r requirements.txt +pytest>=8.3 +pytest-asyncio>=0.24 +httpx>=0.27 +jsonschema>=4.23 +ruff>=0.8 diff --git a/deliverables/pure-agent-dev/requirements.txt b/deliverables/pure-agent-dev/requirements.txt new file mode 100644 index 0000000..d2f7726 --- /dev/null +++ b/deliverables/pure-agent-dev/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115,<1.0 +uvicorn[standard]>=0.32,<1.0 +pydantic>=2.9,<3.0 +pydantic-settings>=2.6,<3.0 diff --git a/deliverables/pure-agent-dev/schemas/agent-task.schema.json b/deliverables/pure-agent-dev/schemas/agent-task.schema.json new file mode 100644 index 0000000..f2ced9b --- /dev/null +++ b/deliverables/pure-agent-dev/schemas/agent-task.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://zyntroai.dev/schemas/agent-task.schema.json", + "title": "AgentTask", + "description": "External contract for an agent task. Pydantic validates at runtime; this file is the published contract.", + "type": "object", + "required": ["task_id", "action"], + "properties": { + "task_id": { + "type": "string", + "minLength": 1 + }, + "action": { + "type": "string", + "enum": [ + "list_instances", + "start_instance", + "stop_instance", + "reboot_instance" + ] + }, + "instance_id": { + "type": ["string", "null"], + "minLength": 1 + } + }, + "allOf": [ + { + "if": { + "properties": { + "action": { + "enum": ["start_instance", "stop_instance", "reboot_instance"] + } + }, + "required": ["action"] + }, + "then": { + "required": ["instance_id"], + "properties": { + "instance_id": { "type": "string", "minLength": 1 } + } + } + } + ], + "additionalProperties": false +} diff --git a/deliverables/pure-agent-dev/tests/__init__.py b/deliverables/pure-agent-dev/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/pure-agent-dev/tests/conftest.py b/deliverables/pure-agent-dev/tests/conftest.py new file mode 100644 index 0000000..c9efa5a --- /dev/null +++ b/deliverables/pure-agent-dev/tests/conftest.py @@ -0,0 +1,37 @@ +"""Shared fixtures. Every test runs without cloud credentials.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +# Make `pure_agent` importable when pytest is run from the deliverable root. +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from pure_agent.agents.executor import AgentExecutor # noqa: E402 +from pure_agent.agents.planner import AgentPlanner # noqa: E402 +from pure_agent.providers.mock import MockComputeProvider # noqa: E402 +from pure_agent.services.compute_service import ComputeService # noqa: E402 + + +@pytest.fixture +def mock_provider() -> MockComputeProvider: + return MockComputeProvider(instances=["i-test-001", "i-test-002"]) + + +@pytest.fixture +def planner() -> AgentPlanner: + # Stable task ids so assertions can be exact. + return AgentPlanner(task_id_factory=lambda: "t-fixed") + + +@pytest.fixture +def service(mock_provider) -> ComputeService: + return ComputeService( + planner=AgentPlanner(task_id_factory=lambda: "t-fixed"), + executor=AgentExecutor(mock_provider), + ) diff --git a/deliverables/pure-agent-dev/tests/test_api.py b/deliverables/pure-agent-dev/tests/test_api.py new file mode 100644 index 0000000..df81ff1 --- /dev/null +++ b/deliverables/pure-agent-dev/tests/test_api.py @@ -0,0 +1,71 @@ +"""API surface: routes are thin and the app boots without credentials.""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from pure_agent.main import app + + +@pytest.fixture +def client(monkeypatch) -> TestClient: + monkeypatch.setenv("COMPUTE_PROVIDER", "mock") + return TestClient(app) + + +def test_health(client): + r = client.get("/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + + +def test_list_instances(client): + r = client.get("/v1/compute/instances") + assert r.status_code == 200 + assert len(r.json()) >= 1 + + +@pytest.mark.parametrize( + "action,verb", + [("start", "start_instance"), ("stop", "stop_instance"), ("reboot", "reboot_instance")], +) +def test_instance_actions_over_http(client, action, verb): + r = client.post(f"/v1/compute/instances/i-mock-001/{action}") + assert r.status_code == 200 + body = r.json() + assert body["instance_id"] == "i-mock-001" + if verb != "list_instances": + assert body["status"] + + +def test_run_structured_task(client): + r = client.post( + "/v1/tasks", + json={"task_id": "t-1", "action": "start_instance", "instance_id": "i-mock-001"}, + ) + assert r.status_code == 200 + assert r.json()["status"] == "starting" + + +def test_run_task_rejects_unknown_action(client): + r = client.post("/v1/tasks", json={"task_id": "t", "action": "drop_database"}) + assert r.status_code == 422 + + +def test_run_task_rejects_missing_instance_id(client): + r = client.post("/v1/tasks", json={"task_id": "t", "action": "start_instance"}) + assert r.status_code == 422 + + +def test_openapi_documents_every_route(client): + paths = client.get("/openapi.json").json()["paths"] + for expected in ( + "/health", + "/v1/tasks", + "/v1/compute/instances", + "/v1/compute/instances/{instance_id}/start", + "/v1/compute/instances/{instance_id}/stop", + "/v1/compute/instances/{instance_id}/reboot", + ): + assert expected in paths diff --git a/deliverables/pure-agent-dev/tests/test_architecture.py b/deliverables/pure-agent-dev/tests/test_architecture.py new file mode 100644 index 0000000..a63d324 --- /dev/null +++ b/deliverables/pure-agent-dev/tests/test_architecture.py @@ -0,0 +1,98 @@ +"""Guard the one rule of Issue #63. + + The Agent must never depend on the BytePlus SDK. + api -> services -> agents -> providers.base -> adapter -> SDK + +A comment cannot enforce that; this test can. It walks the import graph and +fails if any layer reaches across the interface. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +PKG = ROOT / "pure_agent" + +# layer -> modules it is forbidden to import directly +FORBIDDEN = { + "agents": {"pure_agent.providers.byteplus", "pure_agent.providers.mock"}, + "services": {"pure_agent.providers.byteplus", "pure_agent.providers.mock"}, + "schemas": {"pure_agent.providers", "pure_agent.agents", "pure_agent.services"}, +} + +CLOUD_SDKS = ("byteplus", "boto3", "botocore", "azure", "google.cloud") + + +def imported_modules(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + found: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + found.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + found.add(node.module) + return found + + +def layer_files(layer: str) -> list[Path]: + return sorted(p for p in (PKG / layer).rglob("*.py")) + + +@pytest.mark.architecture +@pytest.mark.parametrize("layer", sorted(FORBIDDEN)) +def test_layer_does_not_import_across_the_interface(layer): + banned = FORBIDDEN[layer] + offenders = [] + for path in layer_files(layer): + for module in imported_modules(path): + if any(module == b or module.startswith(b + ".") for b in banned): + offenders.append(f"{path.relative_to(ROOT)} imports {module}") + assert not offenders, ( + f"{layer}/ must depend only on the provider *interface*, not an adapter:\n " + + "\n ".join(offenders) + ) + + +@pytest.mark.architecture +def test_agents_never_reach_a_cloud_sdk(): + offenders = [] + for path in layer_files("agents"): + source = path.read_text(encoding="utf-8").lower() + for sdk in CLOUD_SDKS: + if sdk in source: + offenders.append(f"{path.relative_to(ROOT)} mentions {sdk}") + assert not offenders, "agents/ must not reference a cloud SDK:\n " + "\n ".join(offenders) + + +@pytest.mark.architecture +def test_provider_implementation_is_imported_only_by_the_wiring_layer(): + """Only api/deps.py (and the adapter package itself) may name a concrete adapter.""" + allowed = {"pure_agent/api/deps.py", "pure_agent/providers/byteplus/__init__.py"} + offenders = [] + for path in PKG.rglob("*.py"): + rel = path.relative_to(ROOT).as_posix() + if rel in allowed or rel.startswith("pure_agent/providers/byteplus/"): + continue + for module in imported_modules(path): + if module.startswith("pure_agent.providers.byteplus") or module == "pure_agent.providers.mock": + offenders.append(f"{rel} imports {module}") + assert not offenders, "concrete adapters must be wired in one place:\n " + "\n ".join( + offenders + ) + + +@pytest.mark.architecture +def test_base_interface_is_the_only_provider_dependency_above_it(): + """agents/ + services/ may import providers.base and nothing else under providers/.""" + for layer in ("agents", "services"): + for path in layer_files(layer): + for module in imported_modules(path): + if module.startswith("pure_agent.providers"): + assert module == "pure_agent.providers.base", ( + f"{path.relative_to(ROOT)} imports {module}; " + "only pure_agent.providers.base is allowed here" + ) diff --git a/deliverables/pure-agent-dev/tests/test_compute_service.py b/deliverables/pure-agent-dev/tests/test_compute_service.py new file mode 100644 index 0000000..ffa0e2b --- /dev/null +++ b/deliverables/pure-agent-dev/tests/test_compute_service.py @@ -0,0 +1,38 @@ +"""Service: orchestration only — plan, then execute.""" + +from __future__ import annotations + + +async def test_service_orchestrates_planner_and_executor(service): + result = await service.execute("start_instance", "i-test-001") + assert result.instance_id == "i-test-001" + assert result.status == "starting" + + +async def test_service_lists_instances(service): + result = await service.execute("list_instances") + assert len(result) == 2 + + +async def test_service_passes_instance_id_through(service): + """A regression here would send the wrong id to the provider.""" + result = await service.execute("stop_instance", "i-test-002") + assert result.instance_id == "i-test-002" + + +async def test_service_plans_before_executing(mock_provider): + """If the plan step were skipped, the executor would never be reached.""" + from pure_agent.agents.executor import AgentExecutor + from pure_agent.agents.planner import AgentPlanner + from pure_agent.services.compute_service import ComputeService + + seen = [] + + class SpyPlanner(AgentPlanner): + def plan(self, action, instance_id=None): + seen.append(action) + return super().plan(action, instance_id) + + svc = ComputeService(SpyPlanner(), AgentExecutor(mock_provider)) + await svc.execute("list_instances") + assert seen == ["list_instances"] diff --git a/deliverables/pure-agent-dev/tests/test_executor.py b/deliverables/pure-agent-dev/tests/test_executor.py new file mode 100644 index 0000000..856e200 --- /dev/null +++ b/deliverables/pure-agent-dev/tests/test_executor.py @@ -0,0 +1,77 @@ +"""Executor: AgentTask -> provider, always through the interface.""" + +from __future__ import annotations + +import pytest + +from pure_agent.agents.executor import AgentExecutor, UnsupportedActionError +from pure_agent.schemas.task import AgentTask + + +async def test_execute_routes_each_action(mock_provider): + ex = AgentExecutor(mock_provider) + + listed = await ex.execute(AgentTask(task_id="t", action="list_instances")) + assert [i.instance_id for i in listed] == ["i-test-001", "i-test-002"] + + started = await ex.execute( + AgentTask(task_id="t", action="start_instance", instance_id="i-test-001") + ) + assert started.status == "starting" + + stopped = await ex.execute( + AgentTask(task_id="t", action="stop_instance", instance_id="i-test-001") + ) + assert stopped.status == "stopping" + + rebooted = await ex.execute( + AgentTask(task_id="t", action="reboot_instance", instance_id="i-test-001") + ) + assert rebooted.status == "rebooting" + + +async def test_executor_uses_whatever_provider_it_is_given(mock_provider): + """Swap the provider, the executor is unchanged — the point of the interface.""" + calls = [] + + class Recording: + async def list_instances(self): + calls.append("list") + return [] + + async def start_instance(self, instance_id): + calls.append(("start", instance_id)) + return None + + async def stop_instance(self, instance_id): + calls.append(("stop", instance_id)) + return None + + async def reboot_instance(self, instance_id): + calls.append(("reboot", instance_id)) + return None + + ex = AgentExecutor(Recording()) # type: ignore[arg-type] + await ex.execute(AgentTask(task_id="t", action="list_instances")) + await ex.execute(AgentTask(task_id="t", action="start_instance", instance_id="i-9")) + assert calls == ["list", ("start", "i-9")] + + +async def test_unsupported_action_raises(mock_provider): + ex = AgentExecutor(mock_provider) + task = AgentTask(task_id="t", action="list_instances").model_copy( + update={"action": "nonsense"} + ) + with pytest.raises(UnsupportedActionError): + await ex.execute(task) + + +async def test_executor_depends_only_on_the_interface(mock_provider): + import inspect + + from pure_agent.agents import executor as executor_module + + source = inspect.getsource(executor_module) + assert "from pure_agent.providers.base import ComputeProvider" in source + assert "byteplus" not in source.lower() + assert "mock" not in source.lower() diff --git a/deliverables/pure-agent-dev/tests/test_planner.py b/deliverables/pure-agent-dev/tests/test_planner.py new file mode 100644 index 0000000..d090031 --- /dev/null +++ b/deliverables/pure-agent-dev/tests/test_planner.py @@ -0,0 +1,56 @@ +"""Planner: intent -> structured AgentTask. It must not touch a provider.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from pure_agent.agents.planner import AgentPlanner +from pure_agent.schemas.task import AgentTask + + +def test_plan_builds_a_task(planner): + task = planner.plan("list_instances") + assert isinstance(task, AgentTask) + assert task.task_id == "t-fixed" + assert task.action == "list_instances" + assert task.instance_id is None + + +def test_plan_carries_instance_id(planner): + task = planner.plan("start_instance", "i-42") + assert task.instance_id == "i-42" + + +def test_every_allowed_action_plans(): + planner = AgentPlanner(task_id_factory=lambda: "t") + for action in ("list_instances", "start_instance", "stop_instance", "reboot_instance"): + iid = None if action == "list_instances" else "i-1" + assert planner.plan(action, iid).action == action + + +def test_unknown_action_is_rejected(planner): + with pytest.raises(ValidationError): + planner.plan("delete_everything") + + +@pytest.mark.parametrize("action", ["start_instance", "stop_instance", "reboot_instance"]) +def test_instance_actions_require_instance_id(planner, action): + with pytest.raises(ValidationError): + planner.plan(action) + + +def test_task_id_is_generated_by_default(): + task = AgentPlanner().plan("list_instances") + assert task.task_id # non-empty uuid + + +def test_planner_module_has_no_provider_imports(): + """The strongest form of the rule: the Planner cannot reach a provider at all.""" + import inspect + + from pure_agent.agents import planner as planner_module + + source = inspect.getsource(planner_module) + assert "byteplus" not in source.lower() + assert "providers" not in source diff --git a/deliverables/pure-agent-dev/tests/test_providers.py b/deliverables/pure-agent-dev/tests/test_providers.py new file mode 100644 index 0000000..7d70a4f --- /dev/null +++ b/deliverables/pure-agent-dev/tests/test_providers.py @@ -0,0 +1,93 @@ +"""Provider contract: every adapter must behave identically.""" + +from __future__ import annotations + +import inspect + +import pytest + +from pure_agent.providers.base import ComputeProvider +from pure_agent.providers.mock import MockComputeProvider +from pure_agent.schemas.compute import InstanceResponse + + +class FailingProvider(ComputeProvider): + """A second implementation, to prove the interface is genuinely abstract.""" + + async def list_instances(self): + raise RuntimeError("list not supported") + + async def start_instance(self, instance_id): + return InstanceResponse(instance_id=instance_id, status="starting") + + async def stop_instance(self, instance_id): + return InstanceResponse(instance_id=instance_id, status="stopping") + + async def reboot_instance(self, instance_id): + return InstanceResponse(instance_id=instance_id, status="rebooting") + + +def test_interface_cannot_be_instantiated(): + with pytest.raises(TypeError): + ComputeProvider() # type: ignore[abstract] + + +@pytest.mark.provider +@pytest.mark.parametrize("provider_cls", [MockComputeProvider, FailingProvider]) +async def test_every_provider_implements_the_contract(provider_cls): + provider = provider_cls() + for method in ("list_instances", "start_instance", "stop_instance", "reboot_instance"): + assert callable(getattr(provider, method)) + assert inspect.iscoroutinefunction(getattr(provider, method)) + + +@pytest.mark.provider +async def test_mock_returns_normalised_responses(): + provider = MockComputeProvider(instances=["i-a"]) + assert all(isinstance(i, InstanceResponse) for i in await provider.list_instances()) + for method in ("start_instance", "stop_instance", "reboot_instance"): + resp = await getattr(provider, method)("i-a") + assert isinstance(resp, InstanceResponse) + assert resp.instance_id == "i-a" + + +@pytest.mark.provider +async def test_mock_status_transitions_are_observable(): + provider = MockComputeProvider(instances=["i-a"]) + await provider.stop_instance("i-a") + inst = (await provider.list_instances())[0] + assert inst.status == "stopping" + + +@pytest.mark.provider +def test_byteplus_adapter_satisfies_the_interface_without_sdk_or_credentials(): + """The adapter must be constructible and structurally correct with no SDK present.""" + from pure_agent.providers.byteplus.client import BytePlusClient + from pure_agent.providers.byteplus.ecs import BytePlusECSProvider + + client = BytePlusClient(access_key="k", secret_key="s", region="ap-southeast-1") + provider = BytePlusECSProvider(client) + assert isinstance(provider, ComputeProvider) + assert client.get_region() == "ap-southeast-1" + assert not isinstance(provider, MockComputeProvider) + + +@pytest.mark.provider +def test_byteplus_client_reports_missing_credentials(monkeypatch): + from pure_agent.providers.byteplus.client import BytePlusClient, MissingCredentialError + + monkeypatch.delenv("BYTEPLUS_ACCESS_KEY", raising=False) + monkeypatch.delenv("BYTEPLUS_SECRET_KEY", raising=False) + with pytest.raises(MissingCredentialError) as exc: + BytePlusClient.from_env() + assert "BYTEPLUS_ACCESS_KEY" in str(exc.value) + + +@pytest.mark.provider +def test_byteplus_client_reads_region_from_env(monkeypatch): + from pure_agent.providers.byteplus.client import BytePlusClient + + monkeypatch.setenv("BYTEPLUS_ACCESS_KEY", "k") + monkeypatch.setenv("BYTEPLUS_SECRET_KEY", "s") + monkeypatch.setenv("BYTEPLUS_REGION", "eu-central-1") + assert BytePlusClient.from_env().get_region() == "eu-central-1" diff --git a/deliverables/pure-agent-dev/tests/test_schema_contract.py b/deliverables/pure-agent-dev/tests/test_schema_contract.py new file mode 100644 index 0000000..d3e8663 --- /dev/null +++ b/deliverables/pure-agent-dev/tests/test_schema_contract.py @@ -0,0 +1,74 @@ +"""The Pydantic model and the published JSON Schema must agree. + +Two representations of one contract only stay useful if something checks them +against each other. That is this file. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import jsonschema +import pytest +from pydantic import ValidationError + +from pure_agent.schemas.task import AgentTask + +SCHEMA_PATH = Path(__file__).resolve().parents[1] / "schemas" / "agent-task.schema.json" + + +@pytest.fixture(scope="module") +def schema() -> dict: + return json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + + +def test_schema_file_is_valid_draft_2020_12(schema): + jsonschema.Draft202012Validator.check_schema(schema) + + +def test_valid_task_satisfies_the_json_schema(schema): + task = AgentTask(task_id="t-1", action="start_instance", instance_id="i-1") + jsonschema.validate(task.model_dump(), schema) + + +def test_valid_listing_task_satisfies_the_json_schema(schema): + jsonschema.validate(AgentTask(task_id="t-1", action="list_instances").model_dump(), schema) + + +def test_unknown_action_fails_both_representations(schema): + payload = {"task_id": "t", "action": "delete_everything"} + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(payload, schema) + with pytest.raises(ValidationError): + AgentTask(**payload) + + +def test_missing_instance_id_fails_both_representations(schema): + payload = {"task_id": "t", "action": "start_instance"} + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(payload, schema) + with pytest.raises(ValidationError): + AgentTask(**payload) + + +def test_extra_property_fails_the_json_schema(schema): + payload = {"task_id": "t", "action": "list_instances", "sneaky": 1} + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(payload, schema) + + +def test_action_enum_matches_between_the_two(schema): + """Drift here is the failure that silently breaks every consumer.""" + from typing import get_args + + from pure_agent.schemas.task import Action + + model_actions = set(get_args(Action)) + schema_actions = set(schema["properties"]["action"]["enum"]) + assert model_actions == schema_actions == { + "list_instances", + "start_instance", + "stop_instance", + "reboot_instance", + }