Metadata-Version: 2.5
Name: h3-harness-sdk
Version: 0.1.5
Summary: Python SDK for building H3-compliant agent harnesses
Author: Total Windup Flight Systems
License: MIT
License-File: LICENSE
Keywords: agent,h3,harness,hermes
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.10
Requires-Dist: fastapi>=0.109.0
Requires-Dist: pydantic>=2.0
Requires-Dist: uvicorn[standard]>=0.25.0
Provides-Extra: dev
Requires-Dist: httpx>=0.26; extra == 'dev'
Requires-Dist: jsonschema>=4.20; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest-benchmark>=5.2.3; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.1; extra == 'dev'
Description-Content-Type: text/markdown

# H3 Harness SDK for Python

Python SDK for building H3-compliant agent harnesses.

## Install

```bash
pip install h3-harness-sdk
```

### Install fallback (source / git)

If a release isn't published to PyPI yet (or you want the latest unreleased
changes), install directly from the repository:

```bash
# From git
pip install git+https://github.com/get-h3/sdk-python.git

# Editable source install (development)
git clone https://github.com/get-h3/sdk-python.git
cd sdk-python
pip install -e .
```

**From a clean system** (no `pip` preinstalled — e.g. a minimal Ubuntu
container): bootstrap a venv first, then install into it:

```bash
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
# or the one-command equivalent: make install
```

`make install` creates `.venv` (via `python3 -m venv`), upgrades pip inside
it, and installs the package with dev extras — no system pip required.

## Quickstart

```python
from datetime import datetime, timezone

from h3_harness import (
    BaseHarness,
    Decision,
    DecisionType,
    End,
    TextResponse,
    create_router,
)
from fastapi import FastAPI


class MyHarness(BaseHarness):
    def __init__(self):
        # Track sessions so cancel/session lookups 404 on unknown ids
        # (battery: test_5_9b cancel_unknown_session, test_5_10 session_not_found).
        self._sessions: dict[str, dict] = {}

    async def on_process(self, req):
        # Echo conversation history from context (battery: history preserved).
        history = list(req.context.history)
        # Streaming: "do not finish" in message -> unfinished text.
        streaming = "do not finish" in req.message.content
        finished = not streaming
        self._sessions[req.session_id] = {
            "started_at": datetime.now(timezone.utc).isoformat(),
            "turn_count": (
                self._sessions.get(req.session_id, {}).get("turn_count", 0) + 1
            ),
        }
        return Decision(
            decision=DecisionType.TEXT,
            text=TextResponse(
                content=f"Echo: {req.message.content}",
                finished=finished,
            ),
            history=history,
        )

    async def on_result(self, req):
        return Decision(decision=DecisionType.END, end=End(reason="task_complete"))

    def get_session_info(self, session_id: str) -> dict | None:
        return self._sessions.get(session_id)

    async def on_session_terminate(self, session_id: str) -> None:
        # DELETE /v1/sessions/{id} -> forget the session so a later GET 404s.
        self._sessions.pop(session_id, None)


app = FastAPI()
app.include_router(create_router(MyHarness()))
# Run with: uvicorn my_harness:app --port 9191
```

**Trying it out:** with the server running, send a minimal request. The
payload must include `identity`, `context.config`, and `context.session_state`
(plus `message` and `session_id`) or the router rejects it with a 422:

```bash
curl -X POST http://localhost:9191/v1/process \
  -H 'Content-Type: application/json' \
  -d '{
    "session_id": "sess-001",
    "identity": {"chat_id": "chat-1", "platform": "cli"},
    "message": {"content": "Hello, harness!"},
    "context": {"config": {}, "session_state": {}, "history": []}
  }'
```

→ `{"decision":"text","decision_id":"...","history":[],"text":{"content":"Echo: Hello, harness!","finished":true}}`

Health is at **`/v1/health`** (not `/health`): `curl http://localhost:9191/v1/health`.

### Session lifecycle

`DELETE /v1/sessions/{id}` invokes `on_session_terminate(session_id)` on the
harness (unknown ids 404 at the router before the harness is involved). The
base implementation is a **no-op**, so without an override a terminated
session stays fully retrievable: the DELETE returns `{"terminated": true}`,
but a later `GET /v1/sessions/{id}` still returns 200 with the full session.
The fix is a 3-line cleanup — drop the session from your tracking dict,
exactly as in the quickstart above:

```python
async def on_session_terminate(self, session_id: str) -> None:
    # DELETE /v1/sessions/{id} -> forget the session so a later GET 404s.
    self._sessions.pop(session_id, None)
```

Keep this override in any harness that tracks sessions — it keeps harness
state consistent with what the wire promises (a terminated session 404s on
later GET, like any unknown session).

## Testbed

<!-- Runnable as a plain script: save this block to a file and run `python file.py` -->
```python
import asyncio

from h3_harness.testbed import MockHermes
from h3_harness.examples.echo import EchoHarness


async def main() -> None:
    mock = MockHermes(EchoHarness())
    decision = await mock.send_message("Hello!")
    assert decision.text.content == "Echo: Hello!"


if __name__ == "__main__":
    asyncio.run(main())
```

## Examples

- **[echo.py](src/h3_harness/examples/echo.py)** — Echo harness that mirrors user messages
- **[minimal.py](src/h3_harness/examples/minimal.py)** — Minimal harness with health endpoint, uvicorn runner
- **[langchain_agent.py](src/h3_harness/examples/langchain_agent.py)** — LangChain integration: LLM call with text response

Each example exposes a module-level `app` and a `python -m` runner. Either
works:

```bash
# uvicorn against the module-level app (any port)
uvicorn h3_harness.examples.echo:app --port 9191
uvicorn h3_harness.examples.minimal:app --port 8000

# or the built-in runner (echo.py accepts an optional port argument)
python -m h3_harness.examples.echo 9191
python -m h3_harness.examples.minimal
```

`langchain_agent.py` additionally requires `pip install langchain langchain-openai`.

## Passing the battery (h3-test compliance)

The gate for any H3 harness is the **test battery** (`test_battery.py` from
[get-h3/shim](https://github.com/get-h3/shim) — 45 tests across 6 categories).
Run it against any running harness endpoint:

```bash
# The shim is not yet published to PyPI — install from source (get-h3/shim)
pip install git+https://github.com/get-h3/shim
h3-test --endpoint http://localhost:9191   # exit 0 = compliant
```

The Quickstart harness above implements all four conventions and is fully
battery-compliant (**45/45**). If you modify it, keep the conventions intact —
a naive harness that drops them scores **42/45**. The four conventions the
battery checks (beyond "return a Decision") are:

1. **Echo `context.history` in every Decision returned from `on_process`.**
   The battery sends a session with prior history and asserts it flows back
   through the response (`test_2_8_process_preserves_history`). Pass it
   through explicitly:
   ```python
   history = list(req.context.history)
   return Decision(..., history=history)
   ```
   This applies to decisions returned from `on_process` — the `ProcessRequest`
   carries the `context` field. `on_result` receives a `ResultRequest`, which
   has **no** `context` field (only `decision_id`/`result`/`session_id`); a
   decision returned from `on_result` simply omits `history`. Echoing
   `req.context.history` there raises `AttributeError`.
2. **Never issue `llm_call` when `context.models` is empty.** The battery
   sends `context.models: []` and FAILS any harness that returns an `llm_call`
   decision (`test_5_8_no_models_available` — "hallucinated model"). Only
   return `LLM_CALL` when the request actually lists models.
3. **Return `text.finished=false` for "do not finish" prompts.** The battery
   sends *"Just start a thought, do not finish it yet."* and asserts the
   response has `text.finished == False` (`test_2_4_process_text_finished_false`).
   Detect streaming/unfinished intent and set `finished` accordingly.
4. **404 unknown sessions.** The battery cancels a nonexistent session
   (`test_5_9b cancel_unknown_session`) and GETs one
   (`test_5_10 session_not_found`) and asserts a 404. Track sessions in the
   harness (`get_session_info` returning `None` for unknown ids) — the router
   turns that into the 404.

The canonical battery-ready template is **[echo.py](src/h3_harness/examples/echo.py)**
— it implements all four conventions and scores 45/45. Use it as the starting
point for your own harness.

## Error handling

Exceptions raised inside your harness's `on_process` / `on_result` are
**caught by the router and masked as a successful HTTP 200** `end` decision:

```json
{"decision": "end", "end": {"reason": "error", "summary": "<exception text>"}}
```

This masking **is the current contract** — the spec
(`get-h3/h3` → `specs/04-SDK-Libraries.md`) is silent on handler exceptions,
and the behavior is locked in by `tests/test_handler_crash.py`. From the
shim's point of view the session simply ends: it sees a normal `end` and
stops, so **the session dies silently**. If you need to distinguish a crash
from a real completion, validate the decision — the
`end.reason == "error"` marker (and the server-side
`on_process failed` / `on_result failed` log lines from
`logger.exception`) is the only signal.

Real HTTP 500s are reserved for **non-handler** failures and are not
disturbed by the masking: exceptions from `on_cancel` /
`on_session_terminate` surface as HTTP 500 (`{"detail": ...}`), and any
exception that escapes the router entirely is caught by the logging
middleware's catch-all, which returns an `ErrorResponse` with
`ErrorCode.INTERNAL_ERROR`. Handler-crash coverage in the shim battery
(`get-h3/shim` → `test_battery.py`) belongs to that repo — it is out of
scope for this SDK.

## Result payloads

**`result["tool_calls"]` is a LIST.** When a result carries tool calls
(OpenAI-style LLM APIs), `req.result["tool_calls"]` is a `list[dict]`, one
entry per call — never a single dict:

```python
tool_calls = req.result.get("tool_calls") or []
if not isinstance(tool_calls, list):
    tool_calls = [tool_calls]  # defensive: accept a lone dict too
for call in tool_calls:
    print(call["name"])  # each entry is a plain dict
```

Dict-style access on the list (`req.result["tool_calls"].get("name")`)
raises `AttributeError: 'list' object has no attribute 'get'` — which,
under the error contract above, surfaces as a silent HTTP 200 `end/error`
and kills the session with no signal.

`req.result` itself is a **plain dict** — read fields with `.get()`, never
attribute style (`req.result.type` raises `AttributeError`). The canonical
shape is `ResultPayload`; see `docs/api/protocol.md`.

`GET /v1/sessions/{id}` reports session status: `"active"` (default) or
`"completed"`. Harnesses that track lifecycle return a `status` key from
`get_session_info` (`"completed"` once the loop reaches `end`) and the
router passes it through.

## Development

```bash
make install   # create venv + install deps
make build     # build wheel (and sdist) into dist/
make test      # run tests
make lint      # ruff check
make fmt       # ruff format
```

**Running tests:** use the project venv — `make install` then `.venv/bin/pytest`
(147 tests). Bare `pytest` on an ambient interpreter may fail to import
`h3_harness`; `pytest.ini`'s `pythonpath = src` covers collection from the
source tree without an install, but the project venv is the supported path.

## Reference

- Spec: [get-h3/h3 — specs/04-SDK-Libraries.md](https://github.com/get-h3/h3/blob/main/specs/04-SDK-Libraries.md)
- Protocol: [get-h3/protocol](https://github.com/get-h3/protocol)
- API reference: [docs/api/index.md](docs/api/index.md)
