Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions deliverables/pure-agent-dev/.github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions deliverables/pure-agent-dev/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
.venv/
venv/
.env
*.secret
17 changes: 17 additions & 0 deletions deliverables/pure-agent-dev/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
187 changes: 187 additions & 0 deletions deliverables/pure-agent-dev/README.md
Original file line number Diff line number Diff line change
@@ -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 จริง ซึ่งไม่ต้องแก้ไฟล์อื่นเลย
28 changes: 28 additions & 0 deletions deliverables/pure-agent-dev/agent.yaml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions deliverables/pure-agent-dev/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
services:
api:
build: .
ports:
- "8000:8000"
env_file:
- .env
restart: unless-stopped
14 changes: 14 additions & 0 deletions deliverables/pure-agent-dev/pure_agent/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
Empty file.
46 changes: 46 additions & 0 deletions deliverables/pure-agent-dev/pure_agent/agents/executor.py
Original file line number Diff line number Diff line change
@@ -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)
26 changes: 26 additions & 0 deletions deliverables/pure-agent-dev/pure_agent/agents/planner.py
Original file line number Diff line number Diff line change
@@ -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,
)
Empty file.
Loading
Loading