Skip to content

Who told the agent to do that?

A few weeks ago I kept running into the same blind spot, in different clothes each time: a team's shared ops bot, a coding-agent session two people were driving at once, a Slack bot with more than one person talking to it. Every one of them had an AI agent with a single identity, steered by more than one human -- and when something went wrong, nobody could answer "who told it to do that?"

That question is the entire reason Hansard exists.

Start with the failure case, not the pitch

Here's a real, unedited session. Three people -- priya, sam, jordan -- in three terminals, one shared agent process. Priya asks for a file. Then, while the agent is mid-task, sam and jordan both send messages within a fraction of a second of each other:

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

...

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.

That deploy line is the whole point of this post. Two people spoke close enough together that nobody -- human or tool -- can honestly say which one the agent acted on. Most systems I looked at handle this one of two ways: they don't try to answer the question at all (no concept of "which of several humans caused this"), or they silently pick one and present the guess as if it were recorded fact.

For a tool whose entire job is answering "who caused this," that second failure mode is worse than not having the feature. A confident wrong answer looks like ground truth right up until someone relies on it during an incident review.

Hansard's answer: when it can't tell, it says one of sam or jordan | 0.2 instead of picking a name. That's the design bet the whole system is built around.

Architecture: write never decides, read always explains

The write and read paths are deliberately separate concerns -- nothing about who caused what is ever decided at write time.

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]

A session is a directory, not a file. Every writer -- one per person, one for the agent process -- opens its own append-only .jsonl segment. Concurrent callers never contend on the same file, and a crashed writer can never corrupt anyone else's segment. recorder.py's Session/Turn API is the entire write-side surface: record a message, open a turn, record an action, record what the agent said. Nothing here computes causality -- it just writes down what happened, plus whatever 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 the attribution engine in attribution/ walks that stream to decide who caused each action. This is the part worth dwelling on: attribution is computed fresh on every read and never written back to the log. That one decision means improving a rule retroactively improves every session ever captured -- nothing needs to be re-recorded, migrated, or reprocessed. The log only ever grows; the story we tell about it can keep getting better.

The attribution engine: eight rules, in a fixed order, each a fallback for the last

This is the part that actually does the work. Every attributable event -- an action the agent took, or something it said -- runs through eight rules in a strict cascade. The first one that matches wins:

flowchart TD
    Start(["Attributable event<br/>(an action or output)"]) --> R1{"caused_by<br/>declared?"}
    R1 -->|yes| A1["explicit<br/>confidence 1.0"]
    R1 -->|no| R2{"caused_by<br/>recorded as []?"}
    R2 -->|yes| A2["recorded_no_cause<br/>confidence 1.0"]
    R2 -->|no| R3{"turn declared<br/>context?"}
    R3 -->|yes| A3["turn_context<br/>confidence 0.9"]
    R3 -->|no| R4{"context<br/>recorded as []?"}
    R4 -->|yes| A4["recorded_empty_context<br/>confidence 0.9"]
    R4 -->|no| R5{"retry after a<br/>failed action?"}
    R5 -->|yes| A5["cascade<br/>inherits prior confidence, capped at 0.85"]
    R5 -->|no| R6{"exactly one<br/>unconsumed message?"}
    R6 -->|yes| A6["temporal<br/>confidence 0.6, or 0.4 cross-segment"]
    R6 -->|no| R7{"2+ unconsumed<br/>messages nearby?"}
    R7 -->|yes| A7["contested<br/>confidence 0.3, or 0.2 cross-segment"]
    R7 -->|no| A8["unattributed<br/>confidence 0.0"]

The first four rules are things the agent (or its host application) actually recorded -- explicit and turn_context when a cause was declared, recorded_no_cause and recorded_empty_context when the absence of a cause was declared as a fact. All four sit at 0.9-1.0 confidence because they're not guesses; they're what actually happened, written down.

cascade sits in the middle: if an action is a retry of one that just failed, it inherits the failed attempt's own cause -- capped at whatever confidence that attempt had, never manufacturing certainty the original attribution never earned. If the thing it's retrying was itself contested ("one of sam or jordan"), the retry says so too, instead of quietly asserting a single name.

The last two rules are honest inference from timing alone: temporal when exactly one message was sitting unconsumed before the turn began, contested when two or more were. Both are explicitly labeled as guesses, both carry a plain-English evidence string explaining exactly what was observed, and both are the last resort, only reached when nothing was actually recorded.

The gap this measures

The strongest argument for actually wiring caused_by/context through your integration: 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 hints 0.33 temporal, contested, cascade

Both numbers are from real, captured sessions checked into the repo -- not made up for a pitch deck. Running hansard inspect examples/with-context-hints/s_x --json and the without-hints equivalent reproduces them yourself. The gap is the entire product argument: declaring causality moves attribution from inferred-and-uncertain to recorded-and-exact, and it costs 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=[...])

Why append-only, and why corrections instead of rewrites

The log never gets rewritten, even when something in it turns out to be wrong -- a message misattributed at capture time, a turn that raised after end() was already called. Instead of editing the original record, Hansard appends a correction event that names the target, the field, the new value, and who issued the fix. A read-time pass (apply_corrections) folds corrections into the view you actually see, with the original bytes untouched underneath.

This isn't caution for its own sake. An audit log that can be silently edited after the fact isn't an audit log -- the moment a byte can change without a trace, "what actually happened" stops being a question the log can answer. Corrections give you a fixed transcript and a permanent record that a fix happened, which is the property an audit tool can't compromise on without undermining its own reason for existing.

Making the high-confidence path automatic: the LangGraph adapter

Manually passing caused_by/context works fine when you're writing the integration by hand. It breaks down the moment you're using a framework like LangGraph, where the graph's own executor calls your node functions and your tools -- there's no call site in your code to add a keyword argument to.

So the newest piece is hansard.adapters.langgraph.HansardCallbackHandler: a BaseCallbackHandler that plugs into LangGraph's existing callback machinery and gets you the same 0.94-confidence path automatically, with zero changes to how the graph itself is built.

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]},
    )

Tag who's speaking via additional_kwargs on the messages you already construct, pass the handler at invoke time, done. Under the hood it watches for LangGraph's root-invocation callback (as opposed to the internal per-node calls every framework fires constantly), resolves HumanMessages into Hansard messages, and maps tool calls straight to turn.action()/.result() -- all while staying thread-safe under LangGraph's own parallel node execution.

I verified this against a real OpenRouter-backed agent, not just unit tests: a three-user, three-turn conversation with real tool calls landed 5 out of 5 attributed events at explicit, confidence 1.0. CrewAI and Claude Agent SDK adapters are next, tracked as open issues.

Where it stands

Hansard is MIT licensed, adds zero runtime dependencies, and installs with pip install hansard. It's a library, not a platform -- the whole design constraint is that integrating it should take minutes, and using it shouldn't add anything to your dependency tree unless you opt into a framework adapter.

If you're building anything where more than one person talks to a shared AI agent, I'd genuinely like to know whether this is a problem you've hit, and how you're dealing with it today if so. And if you try it and something's wrong, incomplete, or confusing -- that's exactly the kind of feedback I want.