Skip to content

Commit 789dbc2

Browse files
coordtclaude
andcommitted
Add write-an-agent how-to guide (Task 16, Phase 6)
Documents the foreman-client SDK for agent authors: install, ForemanClient constructor args, next_task/complete_task/heartbeat methods, claim timeout, heartbeat cadence, idempotency contract, and a ≤30-line minimal example. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1156699 commit 789dbc2

2 files changed

Lines changed: 208 additions & 6 deletions

File tree

docs/howtos/write-an-agent.md

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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.

docs/specs/02-messaging-update/plan.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,7 @@ Test startup poll behaviour.
551551

552552
- [x] `uv run pytest --agent-digest=term` — full suite passes (261 tests)
553553
- [x] Reference agent uses `ForemanClient`; no inline protocol models remain
554-
- [ ] Human review before proceeding
554+
- [x] Human review before proceeding
555555

556556
### Phase 6: Documentation and Integration
557557

@@ -564,11 +564,11 @@ heartbeat requirements (every 30 s during long LLM calls), idempotency contract
564564

565565
**Acceptance criteria:**
566566

567-
- [ ] Covers: install, `ForemanClient.__init__` args, `next_task()`, `complete_task()`, `heartbeat()`
568-
- [ ] Explains claim timeout and heartbeat cadence requirement
569-
- [ ] Explains idempotency: what to do if `next_task()` returns an already-processed task
570-
- [ ] Includes a ≤30-line end-to-end example agent using `ForemanClient`
571-
- [ ] Doc is in `docs/how-to/write-an-agent.md`
567+
- [x] Covers: install, `ForemanClient.__init__` args, `next_task()`, `complete_task()`, `heartbeat()`
568+
- [x] Explains claim timeout and heartbeat cadence requirement
569+
- [x] Explains idempotency: what to do if `next_task()` returns an already-processed task
570+
- [x] Includes a ≤30-line end-to-end example agent using `ForemanClient`
571+
- [x] Doc is in `docs/howtos/write-an-agent.md` (project uses `howtos/` convention)
572572

573573
**Verification:**
574574

0 commit comments

Comments
 (0)