Skip to content
iamfahamPublic

About

Audit and replay for multiplayer AI agent sessions

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

Β 

History

148 Commits

Folders and files

Repository files navigation

Hansard

CI PyPI Python 3.13+ License: MIT Docs

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.

Here's what that looks like on a real session: three people, one agent, a message landing mid-task. Hansard reports exactly how confident it is about who caused the deploy below β€” 0.2, not a guess dressed up as fact.

Real, unedited hansard replay output (click to expand)

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/ so you can run hansard replay/inspect yourself and get this exact output β€” see 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.

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.

Architecture

flowchart LR
    A[Person A's client] -->|writes| SA[(w_a.jsonl)]
    B[Person B's client] -->|writes| SB[(w_b.jsonl)]
    AG[Agent process] -->|writes| SC[(w_agent.jsonl)]

    subgraph DIR[Session directory]
        SA
        SB
        SC
    end

    DIR -->|read_session merges by ts, w, seq| M[Merged event stream]
    M --> E[Attribution engine -- read time, never written back]
    E --> C[hansard replay / inspect / verify]
Loading

Write and read are deliberately separate concerns. On the write side (recorder.py's Session/Turn, diagrammed above), nothing about causality is ever decided -- the log only ever records what actually happened: who sent what, what the agent did, and any explicit caused_by/context hints the caller chose to pass.

At read time, store/jsonl.py's read_session() merges every writer's segment into one ordered stream (ts, w, seq), and attribution/'s engine walks that stream applying eight rules in a fixed order, each one a fallback for the last: recorded facts first (explicit, turn_context, and their less-common siblings), then cascade (a retry inherits its cause from the action it's retrying), then timing-based guesses (temporal, contested) for whatever's left. This happens fresh on every read and is never written back to the log, which is why improving a rule improves every session ever captured, retroactively. cli/ (replay, inspect, verify) is the only consumer of that attribution output in this repo today; the same attribute() call is a stable, public entry point anyone could build another reader on top of.

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/ β€” 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.

hansard replay --html <session_dir> renders the same transcript as a single self-contained HTML file β€” no external requests, collapsible turns, a user filter β€” for sharing with someone who wasn't there and doesn't have hansard installed.

Framework adapters

uv add "hansard[langgraph]"

HansardCallbackHandler plugs into a LangGraph agent's own callback machinery and auto-populates caused_by/context from what the graph actually did β€” no manual plumbing at each turn.action()/turn.output() call site.

import hansard
from hansard.adapters.langgraph import HansardCallbackHandler
from langchain_core.messages import HumanMessage

with hansard.session(path="./sessions", agent="support-bot") as sess:
    handler = HansardCallbackHandler(sess)
    graph.invoke(
        {
            "messages": [
                HumanMessage(
                    content="restart the payments worker",
                    additional_kwargs={"hansard_user_id": "priya"},
                )
            ]
        },
        config={"callbacks": [handler]},
    )

Two things to know before relying on this:

  • Give every HumanMessage a stable .id. Without one, a host app that replays its accumulated message history into graph.invoke() on every call (i.e. isn't using a LangGraph checkpointer) will get a duplicate session.message() record for the same logical message each time.
  • The handler's context set accumulates every message it has ever resolved for the life of the handler instance β€” it never shrinks. For a long-lived handler/session, turn_context attribution ends up naming every participant who has ever spoken, not just people relevant to that turn. This is deliberate: a narrower "only this invocation's input state" policy would make turn_context unreachable for checkpointer-based apps, whose inputs typically carry only new messages.

CrewAI and Claude Agent SDK adapters are tracked as future work, not yet started.

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.

Roadmap

Shipped: the capture SDK, the attribution engine, the CLI (replay, verify, inspect, prune), the HTML transcript export, and a first-party LangGraph adapter.

Next up: CrewAI and Claude Agent SDK adapters. Not currently planned: a visual timeline UI, permissions/ approval workflows, or pluggable storage backends β€” the log format is deliberately the whole surface area for now. Have a use case that needs one of these, or something else entirely? Open an issue.

Contributing

Issues and PRs are welcome. uv sync, uv run pytest before opening a PR β€” see CONTRIBUTING.md for repo-specific notes (docs deployment, that kind of thing).

License

MIT.

About

Audit and replay for multiplayer AI agent sessions

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages