H3 Harness SDK for Python
Python SDK for building H3-compliant agent harnesses.
Install
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:
# 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:
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
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:
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:
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
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 — Echo harness that mirrors user messages
- minimal.py — Minimal harness with health endpoint, uvicorn runner
- langchain_agent.py — LangChain integration: LLM call with text response
Each example exposes a module-level app and a python -m runner. Either
works:
# 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 — 45 tests across 6 categories).
Run it against any running harness endpoint:
# 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:
- Echo
context.historyin every Decision returned fromon_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:history = list(req.context.history) return Decision(..., history=history)
This applies to decisions returned fromon_process— theProcessRequestcarries thecontextfield.on_resultreceives aResultRequest, which has nocontextfield (onlydecision_id/result/session_id); a decision returned fromon_resultsimply omitshistory. Echoingreq.context.historythere raisesAttributeError. - Never issue
llm_callwhencontext.modelsis empty. The battery sendscontext.models: []and FAILS any harness that returns anllm_calldecision (test_5_8_no_models_available— "hallucinated model"). Only returnLLM_CALLwhen the request actually lists models. - Return
text.finished=falsefor "do not finish" prompts. The battery sends "Just start a thought, do not finish it yet." and asserts the response hastext.finished == False(test_2_4_process_text_finished_false). Detect streaming/unfinished intent and setfinishedaccordingly. - 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_inforeturningNonefor unknown ids) — the router turns that into the 404.
The canonical battery-ready template is 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:
{"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:
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
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
- Protocol: get-h3/protocol
- API reference: docs/api/index.md
Metadata
Release files for h3-harness-sdk 0.1.5
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| h3_harness_sdk-0.1.5.tar.gz | 168.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| h3_harness_sdk-0.1.5-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 189.0 kB
Release files / h3_harness_sdk-0.1.5.tar.gz
| Download URL | h3_harness_sdk-0.1.5.tar.gz |
|---|---|
| Size | 168.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1d6c653282c546a2d5df4f2a16544380bb0df0d1e136ac36edaeda2504849467
|
|
BLAKE2b-256 checksum How to use checksums |
f585e38440086e92d7b1e528c95dc00626d6d536f936f7711ff1949f2c3fc76c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.17 {"installer":{"name":"uv","version":"0.11.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / h3_harness_sdk-0.1.5-py3-none-any.whl
| Download URL | h3_harness_sdk-0.1.5-py3-none-any.whl |
|---|---|
| Size | 20.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
86ea196ca0f922ae408e00dae42d8930759ac7d630c2f83f6530d41880e5ec6f
|
|
BLAKE2b-256 checksum How to use checksums |
24f01e70d23a2adb465263794be15299159235d07203040adef6363d4c65abb0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.17 {"installer":{"name":"uv","version":"0.11.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|