A grounding firewall for action-taking AI agents: refuse any tool argument the user never actually said, with a transcript-linked audit trail.
Project description
saidso
A grounding firewall for action-taking AI agents.
saidso sits between an AI agent and its consequential tools (book, transfer,
prescribe, refund, update a record) and refuses to let the agent commit any
argument that isn't grounded in what the user actually said — with a
transcript-linked audit trail for every action that does run.
The name is the whole idea: an action only goes through if the user said so.
from saidso import grounded, Policy
@grounded(
name=Policy.SPOKEN, # must appear in the caller's speech
dob=Policy.SPOKEN, # spoken naturally -> normalized to ISO
phone=Policy.CALLER_ID, # comes from carrier metadata, not the mouth
visit_date=Policy.INFERABLE, # "tomorrow" -> resolved from the clock
)
def register_patient(name, dob, phone, visit_date): ...
The problem
LLM voice/phone agents don't just talk — they do things. To do them, they call functions:
register_patient(name="John Doe", dob="1990-01-01", ...)
Sometimes the model fills in arguments the caller never said. Today's frameworks (LiveKit, Vapi, Pipecat, LangGraph) execute the call anyway — and a fabricated name or date of birth lands in a real database.
Prompting ("never make up a DOB") is best-effort. It's the suspect judging itself, it leaves no proof, and it silently degrades as you add tools.
saidso is the backstop that runs in code, not in the prompt: it assumes
the model will hallucinate and refuses to let the hallucination cause harm.
What it does, on every call
- Block — if an argument isn't grounded in the transcript, the function body never runs.
- Steer back — instead of a dead error, it returns a structured message that makes the agent re-ask the caller and try again, in-conversation.
- Attest — for every argument that does go through, it writes a receipt: this value came from these words, at this timestamp, with this confidence.
agent -> register_patient(name='John Doe', dob='1990-01-01')
BLOCKED: body never ran.
steer-back: "I don't have your name and your date of birth from what the
caller said. Ask the caller for your name and your date of
birth, then try again. Do not guess or fill in placeholder values."
The policies
| Policy | A value is grounded if… |
|---|---|
Policy.SPOKEN |
it appears in the caller's speech (digits/dates/names normalized, fuzzy-matched) |
Policy.CONFIRMED |
the agent read it back and the caller affirmed it |
Policy.CALLER_ID |
it matches trusted call metadata, not what was spoken |
Policy.INFERABLE |
it's derivable from context ("tomorrow" + clock) or was spoken |
Install
pip install saidso # zero required dependencies
pip install saidso[fast] # add rapidfuzz for faster matching
saidso works with no third-party packages (stdlib difflib fallback) and
uses rapidfuzz automatically if it's installed.
Usage
from saidso import grounded, Policy, Transcript, call_context, AttestationLog
@grounded(name=Policy.SPOKEN, dob=Policy.SPOKEN)
def register_patient(name, dob):
... # your real DB write
# Feed the conversation as it happens:
tr = Transcript()
tr.add_user("Hi, I'd like to book an appointment.")
log = AttestationLog(path="attestations.jsonl") # optional audit trail
with call_context(tr, ledger=log):
out = register_patient(name="John Doe", dob="1990-01-01")
if getattr(out, "blocked", False):
say_to_caller(out.message) # the agent re-asks; nothing was committed
When grounding passes, the body runs normally and an attestation is recorded.
By default a block returns a SteerBack (so it slots straight into a
tool-use loop); pass GroundingConfig(raise_on_block=True) to raise instead.
Plugging into your agent framework
- Raw OpenAI / Anthropic tool-use — return
steer.to_tool_message()as the tool result so the model self-corrects. Seeexamples/openai_tooluse.py. - LiveKit / Pipecat / Vapi — keep a
Transcriptin sync with the session's transcription events and open acall_context. Seeexamples/livekit_adapter.py.
Regression harness (CI gate)
Assert that invented values are blocked and real ones commit — turn "we hope it doesn't fabricate" into a test:
from saidso.testing import GroundingCase
def test_invented_dob_is_blocked():
(GroundingCase(register_patient)
.user("Hi, I'd like an appointment")
.call(name="John Doe", dob="1990-01-01")
.assert_blocked("name", "dob"))
def test_real_values_commit():
(GroundingCase(register_patient)
.user("It's Maria Gomez, born January first nineteen ninety")
.call(name="Maria Gomez", dob="1990-01-01")
.assert_grounded())
Production behaviour
- Fail-closed. If a grounding check ever raises, the argument is treated as ungrounded (blocked) and the error is logged — a crash never opens the gate.
- Validated at import time. A policy naming a non-existent parameter raises immediately, so a typo can't silently leave a real argument unguarded.
- No silent over-matching. Numbers must match as whole values (
"2"is not grounded by"20"); short names require exact word matches;date,int,float,boolarguments are coerced deterministically. - Observability. Blocks and errors log under the
saidsologger.
Tune thresholds or switch to raising via GroundingConfig:
from saidso import GroundingConfig, Policy
cfg = GroundingConfig(thresholds={Policy.SPOKEN: 0.9}, raise_on_block=True)
@grounded(cfg, name=Policy.SPOKEN)
def book(name): ...
Run the demo
python examples/john_doe_demo.py
Roadmap
The MVP is deterministic-first and intentionally small. Planned next:
verifier-model escalation for ambiguous cases, the anti-priming prompt
compiler, the hallucination regression harness (pytest-style CI gate),
and first-class framework adapters. See Docs/ROADMAP.md.
Development
pip install -e ".[dev]"
pytest -q
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file saidso-0.2.0.tar.gz.
File metadata
- Download URL: saidso-0.2.0.tar.gz
- Upload date:
- Size: 56.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.11 {"installer":{"name":"uv","version":"0.11.11","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
690ed9540550198b11e4c813c724c6bb868d660a124ce1ffe26efbb4ebedbb8c
|
|
| MD5 |
3dda2870bfe621521c5273895977f87d
|
|
| BLAKE2b-256 |
7a002dccb620eba11f1a2ec701ed1bc0b6c05af3d39f382a7888d6ec32b20d7d
|
File details
Details for the file saidso-0.2.0-py3-none-any.whl.
File metadata
- Download URL: saidso-0.2.0-py3-none-any.whl
- Upload date:
- Size: 28.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.11 {"installer":{"name":"uv","version":"0.11.11","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
83c3e15756910601f7cd9cd573d21b4ce611df78f7edf48543d8e3a2a150a7ff
|
|
| MD5 |
b7bc8bca435ce6124afba3a7c103b28d
|
|
| BLAKE2b-256 |
59d1f506684895c5c2d086ca30a3555881872f4d64f7c7ce2dd6761f21ae8ea8
|