clients/python: v0.2 multi-turn Session API
- 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
This commit is contained in:
parent
41a522a469
commit
6a6fc8a67f
7 changed files with 1066 additions and 7 deletions
|
|
@ -33,6 +33,103 @@ 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
|
||||
|
|
@ -97,6 +194,22 @@ 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue