|
| 1 | +--- |
| 2 | +title: Write an Agent |
| 3 | +summary: How to build a Foreman-compatible agent using the foreman-client SDK. |
| 4 | +date: 2026-05-04 |
| 5 | +--- |
| 6 | + |
| 7 | +# Write an Agent |
| 8 | + |
| 9 | +This guide walks you through building a Foreman-compatible agent from scratch. |
| 10 | +Agents are HTTP services that receive task nudges from the harness, claim the task from the queue, process it, |
| 11 | +and report a decision back — all via `ForemanClient`. |
| 12 | + |
| 13 | +## Prerequisites |
| 14 | + |
| 15 | +- Python 3.12+ |
| 16 | +- A running Foreman harness (see [Installation](../installation.md)) |
| 17 | +- `uv` or `pip` for package management |
| 18 | + |
| 19 | +## Install `foreman-client` |
| 20 | + |
| 21 | +```bash |
| 22 | +uv add foreman-client |
| 23 | +# or |
| 24 | +pip install foreman-client |
| 25 | +``` |
| 26 | + |
| 27 | +`foreman-client` has two runtime dependencies: `httpx` and `pydantic>=2`. |
| 28 | + |
| 29 | +## The Three-Method API |
| 30 | + |
| 31 | +`ForemanClient` exposes exactly three methods an agent needs. |
| 32 | + |
| 33 | +### `ForemanClient(harness_url, agent_url)` |
| 34 | + |
| 35 | +| Argument | Type | Description | |
| 36 | +|---------------|-------|-----------------------------------------------------------------------------------| |
| 37 | +| `harness_url` | `str` | Base URL of the Foreman harness (e.g. `"http://localhost:8000"`). | |
| 38 | +| `agent_url` | `str` | This agent's own base URL (e.g. `"http://localhost:9001"`). Sent when claiming tasks so the harness knows which agent holds each claim. | |
| 39 | + |
| 40 | +Use it as a context manager to ensure the HTTP connection pool is closed on exit: |
| 41 | + |
| 42 | +```python |
| 43 | +with ForemanClient(harness_url="http://localhost:8000", agent_url="http://localhost:9001") as client: |
| 44 | + ... |
| 45 | +``` |
| 46 | + |
| 47 | +### `next_task() → TaskMessage | None` |
| 48 | + |
| 49 | +Claims and returns the next pending task from the harness queue. |
| 50 | +Returns `None` when the queue is empty (harness responds `204 No Content`). |
| 51 | +Raises `ForemanClientError` on any non-2xx response. |
| 52 | + |
| 53 | +```python |
| 54 | +task = client.next_task() |
| 55 | +if task is None: |
| 56 | + return # nothing to do |
| 57 | +``` |
| 58 | + |
| 59 | +### `complete_task(task_id, decision)` |
| 60 | + |
| 61 | +Stores the completed `DecisionMessage` in the queue and wakes the harness drain loop. |
| 62 | +Call this once per task, after all processing is done. |
| 63 | + |
| 64 | +| Argument | Type | Description | |
| 65 | +|------------|-------------------|------------------------------------------------------------| |
| 66 | +| `task_id` | `str` | The `task_id` from the `TaskMessage` returned by `next_task()`. | |
| 67 | +| `decision` | `DecisionMessage` | Your agent's decision, rationale, and action list. | |
| 68 | + |
| 69 | +```python |
| 70 | +from foremanclient import DecisionMessage, DecisionType |
| 71 | + |
| 72 | +decision = DecisionMessage( |
| 73 | + task_id=task.task_id, |
| 74 | + decision=DecisionType.label_and_respond, |
| 75 | + rationale="Classified as a bug based on the stack trace.", |
| 76 | + actions=[{"type": "add_label", "label": "bug"}], |
| 77 | +) |
| 78 | +client.complete_task(task.task_id, decision) |
| 79 | +``` |
| 80 | + |
| 81 | +### `heartbeat(task_id)` |
| 82 | + |
| 83 | +Extends the claim window for an in-progress task. |
| 84 | +The harness defaults to a 300-second claim timeout (`claim_timeout_seconds` in `QueueConfig`). |
| 85 | +If your agent hasn't called `complete_task()` within that window, the harness re-queues the task for another attempt. |
| 86 | + |
| 87 | +**Call `heartbeat()` at least once every 30 seconds** during long LLM calls or any blocking work. |
| 88 | + |
| 89 | +```python |
| 90 | +import threading |
| 91 | + |
| 92 | +def _heartbeat_loop(client, task_id, stop_event): |
| 93 | + while not stop_event.wait(timeout=25): |
| 94 | + client.heartbeat(task_id) |
| 95 | + |
| 96 | +stop = threading.Event() |
| 97 | +t = threading.Thread(target=_heartbeat_loop, args=(client, task.task_id, stop), daemon=True) |
| 98 | +t.start() |
| 99 | +try: |
| 100 | + decision = run_llm(task) |
| 101 | +finally: |
| 102 | + stop.set() |
| 103 | +``` |
| 104 | + |
| 105 | +## Idempotency |
| 106 | + |
| 107 | +`task_id` is the idempotency key for every task. |
| 108 | +The harness writes each decision to `action_log` before executing GitHub API calls, keyed on `task_id`. |
| 109 | + |
| 110 | +If `next_task()` returns a task your agent has already completed |
| 111 | +(for example, after an unclean restart), check your own records before processing again: |
| 112 | + |
| 113 | +```python |
| 114 | +task = client.next_task() |
| 115 | +if task and not already_processed(task.task_id): |
| 116 | + decision = process(task) |
| 117 | + client.complete_task(task.task_id, decision) |
| 118 | +``` |
| 119 | + |
| 120 | +The simplest approach is to keep a short in-memory set of recently completed `task_id` values. |
| 121 | +Across restarts, rely on the harness: if the decision is already in `action_log`, the executor skips duplicate actions. |
| 122 | + |
| 123 | +## Minimal Working Example |
| 124 | + |
| 125 | +A complete, runnable agent in under 30 lines: |
| 126 | + |
| 127 | +```python |
| 128 | +import os |
| 129 | +from fastapi import BackgroundTasks, FastAPI |
| 130 | +from foremanclient import DecisionMessage, DecisionType, ForemanClient |
| 131 | +from pydantic import BaseModel |
| 132 | + |
| 133 | +client = ForemanClient(os.environ["FOREMAN_HARNESS_URL"], os.environ["AGENT_URL"]) |
| 134 | +app = FastAPI() |
| 135 | + |
| 136 | +class TaskNudge(BaseModel): |
| 137 | + task_id: str |
| 138 | + |
| 139 | +def _decide(task): |
| 140 | + return DecisionMessage( |
| 141 | + task_id=task.task_id, decision=DecisionType.skip, rationale="No action needed." |
| 142 | + ) |
| 143 | + |
| 144 | +def _run(): |
| 145 | + task = client.next_task() |
| 146 | + if task: |
| 147 | + client.complete_task(task.task_id, _decide(task)) |
| 148 | + |
| 149 | +@app.get("/health") |
| 150 | +def health(): |
| 151 | + return {"status": "ok"} |
| 152 | + |
| 153 | +@app.post("/task", status_code=202) |
| 154 | +async def handle_task(nudge: TaskNudge, background_tasks: BackgroundTasks): |
| 155 | + background_tasks.add_task(_run) |
| 156 | + return {"status": "accepted"} |
| 157 | +``` |
| 158 | + |
| 159 | +Run it with: |
| 160 | + |
| 161 | +```bash |
| 162 | +FOREMAN_HARNESS_URL=http://localhost:8000 AGENT_URL=http://localhost:9001 uvicorn myagent:app --port 9001 |
| 163 | +``` |
| 164 | + |
| 165 | +## Required Endpoints |
| 166 | + |
| 167 | +Every agent **must** expose: |
| 168 | + |
| 169 | +| Method | Path | Description | |
| 170 | +|--------|-----------|------------------------------------------------------------------| |
| 171 | +| `POST` | `/task` | Accept a nudge `{"task_id": "..."}` and return `202 Accepted`. | |
| 172 | +| `GET` | `/health` | Health check. Must return `200 OK` with `{"status": "ok"}`. | |
| 173 | + |
| 174 | +The harness sends a `POST /task` nudge (body: `{"task_id": "..."}`) when a new task is enqueued. |
| 175 | +The agent should return 202 immediately and process the task in a background thread or task. |
| 176 | + |
| 177 | +## Startup Poll |
| 178 | + |
| 179 | +On startup, call `next_task()` once to pick up any tasks that were enqueued while your agent was down: |
| 180 | + |
| 181 | +```python |
| 182 | +from contextlib import asynccontextmanager |
| 183 | + |
| 184 | +@asynccontextmanager |
| 185 | +async def lifespan(app): |
| 186 | + task = client.next_task() |
| 187 | + if task: |
| 188 | + _decide_and_complete(task) |
| 189 | + yield |
| 190 | + client.close() |
| 191 | + |
| 192 | +app = FastAPI(lifespan=lifespan) |
| 193 | +``` |
| 194 | + |
| 195 | +This is the key mechanism for zero task loss under agent restarts. |
| 196 | +The harness re-queues stale claimed tasks after `claim_timeout_seconds`, |
| 197 | +and the startup poll ensures your agent claims them immediately on boot. |
| 198 | + |
| 199 | +## Reference |
| 200 | + |
| 201 | +See the [Agent Protocol Reference](../reference/agent-protocol.md) for the full `TaskMessage`, `DecisionMessage`, |
| 202 | +and `ActionItem` schemas. |
0 commit comments