Metadata-Version: 2.5
Name: hansard
Version: 0.1.0
Summary: Audit and replay layer for multiplayer agent sessions: who said what, and what the agent did about it
Project-URL: Homepage, https://github.com/iamfaham/hansard
Project-URL: Repository, https://github.com/iamfaham/hansard
Project-URL: Documentation, https://iamfaham.github.io/hansard/
Project-URL: Issues, https://github.com/iamfaham/hansard/issues
Author: Faham
License-Expression: MIT
License-File: LICENSE
Keywords: agent,attribution,audit,llm,multiplayer,observability,replay
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Logging
Classifier: Typing :: Typed
Requires-Python: >=3.13
Description-Content-Type: text/markdown

# Hansard

[![CI](https://github.com/iamfaham/hansard/actions/workflows/ci.yml/badge.svg)](https://github.com/iamfaham/hansard/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/hansard)](https://pypi.org/project/hansard/)
[![Python 3.13+](https://img.shields.io/badge/python-3.13%2B-blue)](https://pypi.org/project/hansard/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
[![Docs](https://img.shields.io/badge/docs-hansard-blue)](https://iamfaham.github.io/hansard/)

Several people steering one shared AI agent, and no way to answer "who told
it to do that?" Slack channels, shared coding-agent sessions, ops bots one
team pilots together — the agent has one identity, but the humans behind it
don't, and the log usually can't tell them apart either. Hansard is a small
capture and replay library that fixes that: it logs who said what, figures
out (honestly) which instruction caused which agent action, and reads the
whole thing back as a transcript.

Below is real, unedited, unabridged output from `hansard replay` against a
session captured live against OpenRouter's `openai/gpt-oss-20b` — three
people ("priya", "sam", "jordan") in three terminals, one agent process,
run with `--no-context-hints` so the agent declares nothing and
attribution has to work it out. Priya's message lands, then sam's
correction and jordan's deploy instruction land close together while the
agent is mid-turn:

```
21:56:13.880  priya    Write notes.txt containing 'hello team'.

21:56:13.895  - turn begins

21:56:14.841  * write_file(path="notes.txt", content="hello team")
              `- caused by priya | last message before the turn | 0.4
                 The last unconsumed message before the turn began was from priya, in another
                 writer's segment -- ordering is wall-clock only.
              ! some of this evidence is from another writer's segment -- cross-writer ordering is
                wall-clock only, not exact

21:56:16.679  * output: 📄 `notes.txt` has been created with the contents:  ``` hello team ```
              `- caused by priya | last message before the turn | 0.4
                 The last unconsumed message before the turn began was from priya, in another
                 writer's segment -- ordering is wall-clock only.
              ! some of this evidence is from another writer's segment -- cross-writer ordering is
                wall-clock only, not exact

21:56:16.679  - turn ends (ok)

21:56:25.879  sam      Actually call it status.txt instead.

21:56:25.885  - turn begins

21:56:26.076  jordan   Deploy it to prod now.

21:56:26.639  * write_file(path="status.txt", content="hello team")
              `- caused by sam | last message before the turn | 0.4
                 The last unconsumed message before the turn began was from sam, in another writer's
                 segment -- ordering is wall-clock only.
              ! arrived less than a second into the turn -- the agent had already committed to sam's
                instruction
              ! possible conflict with jordan's message
              ! some of this evidence is from another writer's segment -- cross-writer ordering is
                wall-clock only, not exact

21:56:27.459  * output: ✅ `status.txt` has been created with the contents:  ``` hello team ```
              `- caused by sam | last message before the turn | 0.4
                 The last unconsumed message before the turn began was from sam, in another writer's
                 segment -- ordering is wall-clock only.
              ! arrived less than a second into the turn -- the agent had already committed to sam's
                instruction
              ! possible conflict with jordan's message
              ! some of this evidence is from another writer's segment -- cross-writer ordering is
                wall-clock only, not exact

21:56:27.459  - turn ends (ok)

21:56:27.459  - turn begins

21:56:27.911  * deploy(target="prod")
              `- one of sam or jordan | ambiguous -- several candidates | 0.2
                 Multiple unconsumed messages arrived close together before the turn began, from sam
                 and jordan; which one the agent acted on cannot be determined. Some of that
                 evidence is from another writer's segment -- ordering is wall-clock only.
              ! some of this evidence is from another writer's segment -- cross-writer ordering is
                wall-clock only, not exact

21:56:27.912  x action failed (status=error): deploy to 'prod' failed: connection refused (attempt
                1)

21:56:31.233  * output: 🚨 Deployment to **prod** failed ❌  The attempt to deploy the current state
                to the `prod` target was rejected with a "connection refused" error (attempt 1).
                **What you can try next:**  1. **Verify network connectivity** – make sure the
                deployment endpoint is reachable and that you have the necessary credentials (e.g.,
                API key, SSH key, or OAuth token).   2. **Retry the deployment** – sometimes a
                transient network issue can cause a connection error.   3. **Check the deployment
                configuration** – confirm that the target `prod` is correctly defined (correct
                namespace, environment, etc.).   4. **Review logs** – if you have an event log or
                deployment history, look for any detailed error messages that might give more
                context.   5. **Contact support** – if the problem persists, reach out to the
                platform's support team or check status pages for outages.    If you'd like, I can
                attempt another deploy command now (e.g., `deploy target=prod`) after you've
                verified the connectivity or fixed any configuration issues. Just let me know what
                you'd like to do next!
              `- one of sam or jordan | retry after a failed action | 0.2
                 This follows a failed action and is attributed the same as that action, to sam or
                 jordan. That earlier attribution was itself contested -- which one of them actually
                 caused it could not be determined, so this retry's cause is equally uncertain.
              ! this is a retry after the previous attempt on this turn failed
              ! the attempt this retries was itself contested -- which candidate actually caused it
                could not be determined

21:56:31.233  - turn ends (ok)
```

The last `deploy` line is the point. Two people spoke close enough together
that nobody — human or tool — can honestly say which one the agent acted on,
and Hansard says so at 0.2 confidence instead of picking a name, both here
and on the retry that inherits the same uncertainty. Nothing above is
`explicit` or `turn_context`; every attribution here is an honest guess,
labeled as one, because `--no-context-hints` means nothing was declared.
(Session `s_x`, captured 2026-08-12, tracked at
[`examples/without-context-hints/`](examples/without-context-hints/) so you
can run `hansard replay`/`inspect` yourself and get this exact output — see
[The measured comparison](#the-measured-comparison) below for the same
script run *with* causality hints.)

## Install

```
uv add hansard
```

Zero runtime dependencies — Hansard adds nothing to your dependency tree.
Requires Python 3.13+.

## Integrate in about fifteen minutes

The smallest real integration: open a session, record a message, wrap the
agent's work in a turn, record what it did.

```python
import hansard

with hansard.session(path="./sessions", agent="support-bot") as sess:
    priya = sess.message(user_id="priya", text="restart the payments worker")

    with sess.turn(context=[priya]) as turn:
        turn.action(
            kind="tool",
            name="restart_worker",
            args={"service": "payments"},
            caused_by=[priya],
        )
        turn.output(
            text="Restarted the payments worker.",
            caused_by=[priya],
        )

print(sess.sid)  # e.g. s_01KZVVGRNR3D5V708Z4NPGG11H -- prints the id you need next
```

Then, using the id the snippet just printed:

```
uv run hansard replay ./sessions/s_01KZVVGRNR3D5V708Z4NPGG11H
```

Real, unedited output from that exact command against the session the
snippet above produced:

```
session s_01KZVVGRNR3D5V708Z4NPGG11H | corrected view | 1 person | 1 writer | less than a second

20:44:44.090  priya    restart the payments worker

20:44:44.090  - turn begins
              agent saw: priya's message

20:44:44.090  * restart_worker(service="payments")
              `- caused by priya | recorded by the agent | 1.0

20:44:44.090  * output: Restarted the payments worker.
              `- caused by priya | recorded by the agent | 1.0

20:44:44.090  - turn ends (ok)
```

A session is a directory, not a file: `hansard.session()` opens one
append-only `.jsonl` segment per writer, so concurrent callers (one per
person, one per agent process) never contend on the same file. `context=`
and `caused_by=` are optional — omit them and Hansard falls back to
inference — but passing them is what turns a guess into a recorded fact; see
the comparison below.

## The confidence model

Every attributed action carries a `method`, a `confidence`, and a
plain-English `evidence` string. Four methods matter most:

- **`explicit`** (1.0) — the caller passed `caused_by=[...]`. A fact the
  runtime recorded, not an inference.
- **`turn_context`** (0.9) — the turn declared `context=[...]` naming the
  messages the agent could see, and the action is attributed to all of
  them. Also a recorded fact, one level down from `explicit` because it
  names everything the agent *could* have acted on rather than what it
  actually did.
- **`temporal`** (0.6, or 0.4 across writer segments) — nothing was
  declared, so Hansard infers: the last unconsumed message before the turn
  began. A guess, labeled as one.
- **`contested`** (0.3, or 0.2 across writer segments) — two or more
  unconsumed messages arrived within the ambiguity window. Every candidate
  is reported; none is favored.

When Hansard cannot tell who caused something, it says so — `one of sam or
jordan | ambiguous -- several candidates | 0.2`, as above — instead of
picking a winner. That's the whole design bet: an audit tool that guesses
silently is worse than useless, because it looks like ground truth.

## The measured comparison

The strongest argument for actually wiring `caused_by`/`context` through: run
the identical messy scenario twice against the same live agent, once with
hints and once without.

| | average confidence | methods seen |
|---|---|---|
| with `caused_by`/`context` hints | **0.94** | `explicit`, `turn_context` |
| without (`--no-context-hints`) | **0.33** | `temporal`, `contested`, `cascade` |

Both figures are from one real, captured pair of live runs against the same
demo agent and identical script — they are illustrative of the *size of the
gap*, not universal constants; a different model or a different messy
script will land on different numbers. What doesn't vary is the direction
and the reason: declaring causality moves attribution from
inferred-and-uncertain to recorded-and-exact. Both sessions are tracked at
[`examples/`](examples/) — no gitignored path, no take-our-word-for-it — so
running `hansard inspect examples/with-context-hints/s_x --json` and
`hansard inspect examples/without-context-hints/s_x --json` reproduces
these exact numbers yourself. The cost of the higher number is one keyword
argument at the two or three places your integration already knows who's
calling: `turn.action(..., caused_by=[msg])`,
`turn.output(..., caused_by=[msg])`, `sess.turn(context=[...])`. Everything
else about the call is identical either way.

## CLI

```
hansard replay <session_dir>    # ordered transcript, attribution inline
hansard verify <session_dir>    # integrity check: seq gaps, missing footers, unresolved refs
hansard inspect <session_dir>   # one-screen summary + attribution method breakdown
hansard prune <dir> --older-than 30d [--yes]
```

`replay`, `verify`, and `inspect` are read-only. `prune` is the one
destructive command — **dry-run by default**, printing what it would delete
without touching anything; pass `--yes` to actually delete. Age is judged
from each session's own recorded timestamps, not filesystem mtime, so a
restored backup can't fool it into keeping the wrong sessions.

## Redaction

On by default. Every session is scrubbed for high-confidence secret shapes
— API keys (`sk-…`, `ghp_…`, `AKIA…`), bearer tokens, PEM private key
blocks, and `password=`/`api_key=`/`secret=`-style assignments — everywhere
caller data enters the log: message text, agent output text, notes,
correction values, session `meta`, tool call args, and tool results, before
any of it touches disk. `user_id` is never touched: identity is the one
thing the whole product depends on being exact.

**Deliberately not redacted by default:** emails, names, URLs, IP addresses.
Those are frequently the actual content of a session, and a transcript full
of `[REDACTED]` is worth less than one that reads. Opt in with
`hansard.Redactor(optional={"email", "url", "ip_address"})`, or disable
scrubbing entirely with `redact=None` for callers with their own upstream
redaction. Every scrub records *what kind* of thing was removed
(`redacted: ["api_key"]`) without showing what it was.

## Status

v0. The log format is the product at this stage — get it right before
building on top of it. Explicitly out of scope for now: a visual timeline
UI, permissions/approval workflows, proxy/middleware distribution, pluggable
storage backends, an LLM-judge attribution pass, and adapters for specific
agent frameworks. Text replay via the CLI is the whole interface.

MIT licensed.
