- Session class wrapping a clawdforge session_id; context-manager auto-close
- forge.session(agent=...) block form (preferred)
- forge.create_session() / forge.list_sessions() / forge.get_session() admin shapes
- TurnResult dataclass with .text() helper concatenating text events
- Idempotent Session.close() — safe in finally / __exit__
- tests/test_sessions.py: 16 tests covering block/manual/idempotency/exception/list/state/text-helper/v0.1-regression
- README "Multi-turn / Sessions (v0.2)" section
- pyproject version 0.1.0 -> 0.2.0; package __version__ matches
Architecture: matches the existing v0.1 client — sync, requests-based,
single Forge-owned requests.Session for connection pooling. Session holds
a back-reference to the Forge for HTTP work (no per-Session HTTP client).
This mirrors how Forge already exposes its own context-manager pattern, so
nothing about the threading/lifecycle story changes for callers.
v0.1 /run path unchanged — 49 existing tests still green, +16 new tests
for v0.2 (target was 9; covered the spec's 9 plus extras for empty-prompt
local validation, include_closed=false param, empty-id ValueError, and
s.state() round-trip).
mypy --strict src/clawdforge/ clean.
Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
284 lines
10 KiB
Markdown
284 lines
10 KiB
Markdown
# clawdforge — Python SDK
|
|
|
|
Sync Python client for the [clawdforge](http://192.168.0.5:3001/Sulkta-Coop/clawdforge) HTTP service. Wraps the bearer-token-gated REST API behind a typed dataclass surface so other Sulkta apps (cauldron, petalparse, johnny5) don't have to hand-roll `requests` calls.
|
|
|
|
## Install
|
|
|
|
```bash
|
|
pip install -e clients/python/
|
|
```
|
|
|
|
(LAN-only, not on PyPI.)
|
|
|
|
## Quickstart
|
|
|
|
```python
|
|
from clawdforge import Forge
|
|
|
|
forge = Forge(base_url="http://192.168.0.5:8800", token="cf_...")
|
|
|
|
print(forge.healthz())
|
|
# {'ok': True, 'claude_present': True, 'claude_version': '1.x.y'}
|
|
|
|
r = forge.run(prompt='Reply with JSON: {"hello": "world"}')
|
|
print(r.result) # {'hello': 'world'}
|
|
print(r.duration_ms) # 4321
|
|
print(r.stop_reason) # 'end_turn'
|
|
```
|
|
|
|
`Forge` is also a context manager — preferred when you have a script-shaped lifetime:
|
|
|
|
```python
|
|
with Forge(base_url="...", token="...") as forge:
|
|
forge.run(prompt="...")
|
|
```
|
|
|
|
## Multi-turn / Sessions (v0.2)
|
|
|
|
For agent-shaped workflows that span multiple turns, use a `Session`. A session keeps acpx context across `turn()` calls — file reads, tool-call results, and conversational state persist across turns until the session is closed (or hits the server's TTL).
|
|
|
|
### Block form (preferred — auto-close)
|
|
|
|
```python
|
|
with forge.session(agent="claude") as s:
|
|
r1 = s.turn("Read README.md and summarize it")
|
|
print(r1.text()) # concatenated 'text' events from the model
|
|
|
|
r2 = s.turn("Now look at the auth flow")
|
|
print(r2.duration_ms) # 12345
|
|
for ev in r2.events:
|
|
print(ev["type"], ev.get("content"))
|
|
|
|
# Session is closed automatically here, even if an exception was raised
|
|
# inside the block.
|
|
```
|
|
|
|
### Manual form (explicit lifecycle)
|
|
|
|
For callers that need a Session to outlive a single block (background workers, cross-handler state):
|
|
|
|
```python
|
|
s = forge.create_session(agent="claude", meta={"caller": "petalparse"})
|
|
try:
|
|
r = s.turn("hello")
|
|
finally:
|
|
s.close() # idempotent — safe to call repeatedly, including on a
|
|
# session that was already closed by the server's TTL sweeper
|
|
```
|
|
|
|
`Session.close()` is idempotent: subsequent calls short-circuit without an HTTP request, and the server itself returns 200 (`already_closed: true`) on a repeat DELETE. Safe to drop in `finally` and `__exit__` blocks without `try/except`.
|
|
|
|
### TurnResult shape
|
|
|
|
```python
|
|
@dataclass(frozen=True)
|
|
class TurnResult:
|
|
ok: bool
|
|
session_id: str
|
|
turn_index: int # 1-based, monotonic per session
|
|
events: list[dict] # 'text' | 'tool_call' | 'thinking' | ...
|
|
stop_reason: str | None # 'end_turn' | 'timeout' | 'error' | ...
|
|
duration_ms: int
|
|
|
|
def text(self) -> str: ... # concat of all 'text' events' content
|
|
```
|
|
|
|
`events` is passed through verbatim from the v0.2 server — see the server doc for the exhaustive event-type schema. `text()` is sugar for "give me the model's prose reply, ignore thinking and tool calls."
|
|
|
|
### Listing & inspection
|
|
|
|
```python
|
|
# All sessions for the calling token (newest first):
|
|
sessions = forge.list_sessions()
|
|
for row in sessions:
|
|
print(row["session_id"], row["turn_count"], row["closed_at"])
|
|
|
|
# Drop closed ones:
|
|
live = forge.list_sessions(include_closed=False)
|
|
|
|
# Fresh state for a specific session:
|
|
state = forge.get_session("sess_abc")
|
|
# {'session_id': 'sess_abc', 'agent': 'claude', 'cwd': ...,
|
|
# 'created_at': ..., 'last_turn_at': ..., 'turn_count': 3,
|
|
# 'closed_at': None, 'live': True, 'meta': {...}}
|
|
|
|
# Same thing from inside a block:
|
|
with forge.session() as s:
|
|
s.turn("hi")
|
|
print(s.state()["turn_count"])
|
|
```
|
|
|
|
### Errors
|
|
|
|
The same exception classes apply as v0.1. Two cases worth calling out:
|
|
|
|
- **Cross-token access → `ForgeAPIError(404)`.** If token A asks for token B's session, the server returns 404 (not 403) so it can't be used to enumerate live session ids across token boundaries.
|
|
- **Session closed mid-flight → `ForgeAPIError(410)`.** The TTL sweeper or another caller's explicit `close()` may have ended the session between turns.
|
|
|
|
```python
|
|
from clawdforge import ForgeAPIError
|
|
|
|
try:
|
|
r = s.turn("hello")
|
|
except ForgeAPIError as e:
|
|
if e.status_code == 410:
|
|
# Session expired or was closed — start a fresh one.
|
|
...
|
|
elif e.status_code == 404:
|
|
# Server forgot us, or we're asking for someone else's id.
|
|
...
|
|
raise
|
|
```
|
|
|
|
## Construction
|
|
|
|
```python
|
|
Forge(
|
|
*,
|
|
base_url: str,
|
|
token: str,
|
|
default_model: str = "sonnet",
|
|
default_timeout_secs: int = 120,
|
|
http_timeout_margin: int = 30,
|
|
session: requests.Session | None = None,
|
|
)
|
|
```
|
|
|
|
- `base_url` — `http://host:port`. Trailing slash stripped.
|
|
- `token` — bearer. App token (`cf_...`) for `/run` and `/files`; admin bootstrap token for `/admin/*`.
|
|
- `default_model` — passed to `/run` when caller doesn't override. Server default is also `sonnet` so this matches.
|
|
- `default_timeout_secs` — `timeout_secs` for `/run` when caller doesn't override.
|
|
- `http_timeout_margin` — seconds added to the run subprocess timeout to derive the HTTP-level timeout. The HTTP deadline must outlast the server's subprocess deadline so we don't bail while clawdforge is still doing legitimate work for us. Default 30s — same pattern cauldron has been running inline.
|
|
- `session` — bring your own `requests.Session` if you want shared connection-pooling across multiple Forges. If omitted, the Forge owns one and closes it on exit.
|
|
|
|
## Methods
|
|
|
|
### `healthz() -> dict`
|
|
|
|
```python
|
|
forge.healthz()
|
|
# {'ok': True, 'claude_present': True, 'claude_version': '1.x.y'}
|
|
```
|
|
|
|
### `run(prompt, *, model=None, system=None, files=None, timeout_secs=None) -> RunResult`
|
|
|
|
```python
|
|
r = forge.run(
|
|
prompt="Sterilize this ingredient line: 'about 2 cups of cooked white rice'",
|
|
model="sonnet", # optional, defaults to forge.default_model
|
|
system="You are a precise recipe parser. Always reply with valid JSON.",
|
|
files=["ff_..."], # optional file tokens from upload_file()
|
|
timeout_secs=60, # optional, 5..600, defaults to forge.default_timeout_secs
|
|
)
|
|
|
|
r.ok # True
|
|
r.result # parsed JSON when claude returned valid JSON, else str
|
|
r.duration_ms # 4321
|
|
r.stop_reason # 'end_turn' | 'timeout' | 'error' | ...
|
|
```
|
|
|
|
### `upload_file(path_or_fileobj, *, ttl_secs=3600, filename=None, content_type=None) -> FileToken`
|
|
|
|
Either path-like or a binary file-object:
|
|
|
|
```python
|
|
ft = forge.upload_file("/path/to/recipe.png", ttl_secs=3600)
|
|
# FileToken(file_token='ff_...', ttl_secs=3600, size=12345)
|
|
|
|
# Or from a buffer:
|
|
import io
|
|
ft = forge.upload_file(io.BytesIO(b"..."), filename="snippet.txt")
|
|
|
|
r = forge.run(prompt="Extract recipe data", files=[ft.file_token])
|
|
```
|
|
|
|
The server enforces `60 <= ttl_secs <= 86400` and returns 400 for out-of-range values, surfaced as `ForgeAPIError`.
|
|
|
|
### `session(*, agent="claude", meta=None) -> ContextManager[Session]` (v0.2)
|
|
|
|
Block-form session — auto-closes on `__exit__`. See the "Multi-turn / Sessions (v0.2)" section above.
|
|
|
|
### `create_session(*, agent="claude", meta=None) -> Session` (v0.2)
|
|
|
|
Manual-lifecycle session. Caller is responsible for `s.close()` (typically in `finally`). `close()` is idempotent.
|
|
|
|
### `list_sessions(*, include_closed=True) -> list[dict]` (v0.2)
|
|
|
|
List sessions for the calling token. Pass `include_closed=False` to skip closed rows.
|
|
|
|
### `get_session(session_id) -> dict` (v0.2)
|
|
|
|
Fetch fresh server-side state for a session. Raises `ForgeAPIError(404)` if the id is unknown or belongs to another token.
|
|
|
|
### Admin (admin-bootstrap-token gated)
|
|
|
|
```python
|
|
admin = Forge(base_url="http://192.168.0.5:8800", token=ADMIN_BOOTSTRAP_TOKEN)
|
|
|
|
t = admin.create_token("cauldron", ip_cidrs=["172.24.0.0/16"])
|
|
# AppToken(name='cauldron', token='cf_brandnew_xxx', ip_cidrs=['172.24.0.0/16'], ...)
|
|
# t.token is the plaintext bearer — store it now, the server only keeps a hash.
|
|
|
|
admin.list_tokens()
|
|
# [AppToken(name='cauldron', token=None, ip_cidrs=['172.24.0.0/16'], created_at=..., last_used=..., enabled=True), ...]
|
|
|
|
admin.revoke_token("cauldron") # True on success; raises ForgeAPIError(404) if no such token
|
|
```
|
|
|
|
## Errors
|
|
|
|
Everything the SDK raises descends from `ForgeError`:
|
|
|
|
```
|
|
ForgeError
|
|
├── ForgeTransportError — connection/TCP-timeout failures (server never responded)
|
|
└── ForgeAPIError — server returned 4xx/5xx; .status_code, .body available
|
|
└── ForgeAuthError — 401 or 403 specifically
|
|
```
|
|
|
|
`ForgeAPIError` carries:
|
|
- `.status_code: int`
|
|
- `.body: dict | str | None` — parsed JSON if available, else text
|
|
- `.message: str` — short summary
|
|
|
|
Typical pattern:
|
|
|
|
```python
|
|
from clawdforge import Forge, ForgeAuthError, ForgeAPIError, ForgeTransportError
|
|
|
|
try:
|
|
r = forge.run(prompt="...")
|
|
except ForgeAuthError:
|
|
# Re-mint your token, or check the IP allowlist.
|
|
raise
|
|
except ForgeAPIError as e:
|
|
if e.status_code == 502 and isinstance(e.body, dict):
|
|
# Subprocess failed/timed out. e.body has 'error', 'stderr',
|
|
# 'duration_ms', 'stop_reason'.
|
|
log.warning("clawdforge run failed: %s", e.body.get("error"))
|
|
raise
|
|
except ForgeTransportError:
|
|
# Network blip — clawdforge is down or unreachable. Caller decides
|
|
# whether to retry; the SDK does not retry.
|
|
raise
|
|
```
|
|
|
|
## No retries by default
|
|
|
|
Clawdforge runs are not idempotent — each `/run` spawns a real `claude -p` subprocess that costs tokens and time. If you want retries, wrap calls with whatever retry policy fits your call-site (tenacity, backoff, hand-rolled). The SDK won't double-charge you behind your back.
|
|
|
|
## Tests
|
|
|
|
```bash
|
|
cd clients/python
|
|
pip install -e .[test]
|
|
python -m unittest discover tests
|
|
```
|
|
|
|
Tests use `responses` to intercept HTTP calls — no live network, no live clawdforge.
|
|
|
|
## Notes
|
|
|
|
- The `result` field of `RunResult` matches whatever the server's `runner` parsed out of `claude -p --output-format json`: a dict when the prompt asked for JSON and the model complied, a string otherwise. The SDK passes it through as-is — your prompt determines the shape.
|
|
- The server's `GET /admin/tokens` returns `ip_cidrs` as a comma-joined string (it's stored that way in SQLite). The SDK normalizes that to `list[str]` on `AppToken`. The create response already returns a list, so both shapes round-trip cleanly.
|
|
- `revoke_token` on a non-existent name raises `ForgeAPIError(404)` rather than returning `False`. That matches the server's contract and lets callers distinguish "didn't exist" from "was disabled".
|