Skip to main content

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 consequences and enforces one rule:

Nothing is committed (a tool argument) or spoken (a fact) unless it traces back to something real — what the user said, or what a tool returned. Anything ungrounded is blocked, before it can cause harm.

The name is the whole idea: an action only goes through if the user said so.

pip install saidso          # zero required dependencies
pip install saidso[fast]    # optional: rapidfuzz for faster matching

The problem

LLM agents (especially voice/phone agents) don't just talk — they do things and state facts. Sometimes the model makes things up: an argument the caller never said, an appointment ID that doesn't exist, a doctor's name nobody offered. Today's frameworks execute the call and speak the words anyway — and a fabricated value lands in a real database, or a wrong fact reaches a real patient.

Prompting ("never make up a DOB") is best-effort: it's the suspect judging itself, it leaves no proof, and it degrades as you add tools. saidso runs in code, not the prompt — it assumes the model will hallucinate and refuses to let it matter.


Two guarantees

1. Writes — what the agent does

Guard a tool's arguments. They must trace to the caller's words or to real tool output. Fail-closed: ungrounded → the body never runs.

from saidso import grounded, grounded_outputs, Policy, from_tool

# Ground against the CONVERSATION
@grounded(name=Policy.SPOKEN, dob=Policy.SPOKEN, phone=Policy.CALLER_ID)
def register_patient(name, dob, phone): ...

# Ground against an earlier TOOL'S OUTPUT (provenance)
@grounded_outputs(
    slot_start=from_tool("get_slots", "slot_start", normalize="datetime-minute")
)
def book_appointment(slot_start): ...
  • A fabricated id/slot/name → blocked, the agent is steered to re-ask.
  • A value the model rebuilt slightly wrong (right time, wrong timezone) → it's rewritten to the canonical value the tool actually returned, then committed.

2. Reads — what the agent says

A native-audio model can't have its mouth gated. So saidso doesn't gate it — it builds the consequential line from grounded data and refuses if any fact is made up. Your TTS speaks the verified string (saidso is TTS-agnostic — it never produces audio).

from saidso import render_spoken, fact

line = render_spoken(
    "You're booked with {doctor} at {time}.",
    ledger=tool_ledger,
    doctor=fact("Dr. Rashmi", ("list_doctors", "doctor_name")),
    time=fact(slot_start, ("get_slots", "slot_start"),
              normalize="datetime-minute", render=to_clock),
)
# -> "You're booked with Dr. Rashmi at 5:00 PM."   (every fact verified)
# a fabricated value -> raises UngroundedSpeech, produces nothing

The policies (@grounded)

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

Block → steer back → attest

On every guarded call, saidso:

  1. Blocks — an ungrounded argument means the body never runs.
  2. Steers back — returns a SteerBack whose .message makes the agent re-ask the caller in-conversation (or set raise_on_block=True to raise).
  3. Attests — every value that passes writes a receipt: this value came from these words, at this time, with this confidence.
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

tr = Transcript()
tr.add_user("It's Maria Gomez, born January first nineteen ninety.")
audit = AttestationLog(path="audit.jsonl")          # optional audit trail

with call_context(tr, ledger=audit):
    out = register_patient(name="Maria Gomez", dob="1990-01-01")  # ✅ commits

if getattr(out, "blocked", False):
    say_to_caller(out.message)          # ❌ nothing was committed; agent re-asks

Observability

Every decision emits a structured event on the saidso logger. A zero-dependency console makes it readable:

from saidso import enable_pretty_logging, EventRecorder, summary

enable_pretty_logging()             # colored ✓/✗ live stream (auto-off when not a TTY)
rec = EventRecorder().attach()
# ... run your agent ...
print(summary(audit, rec))
13:38:15 ✓ grounded register_patient  name, dob
13:38:15 ✗ blocked  book_appointment  slot_start
┌─ saidso — 1 grounded, 1 blocked
  ✓ register_patient       name, dob
  ✗ book_appointment       slot_start
└──────────────────────────────────────

Regression harness (CI gate)

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"))

Why it's production-grade

  • Fail-closed — if a check ever raises, the value is treated as ungrounded; 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.
  • Deterministic & fast — pure Python, in-process; a write check is ~12µs (~1/2000th of a single backend call). No perceptible latency in a voice agent.
  • Zero required dependenciesrapidfuzz used if present, stdlib difflib otherwise. Ships py.typed.
  • Model- & platform-agnostic — no model SDK is imported anywhere. Works with Gemini Live, OpenAI Realtime, cascaded STT→LLM→TTS pipelines, or text agents.

CLI

saidso is a library (it runs inside your agent), with a small CLI for convenience:

saidso --help               # list all commands
saidso version              # print the installed version
saidso docs                 # read the docs in your terminal (saidso docs --list)
saidso upgrade              # upgrade to the latest release on PyPI
saidso quickstart           # scaffold a runnable demo + GETTING_STARTED.md
saidso quickstart my-dir    # ...into a folder of your choice
python -m saidso version    # module form also works

Examples & docs

Development

pip install -e ".[dev]"
pytest -q

License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

saidso-0.4.2.tar.gz (79.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

saidso-0.4.2-py3-none-any.whl (53.4 kB view details)

Uploaded Python 3

File details

Details for the file saidso-0.4.2.tar.gz.

File metadata

  • Download URL: saidso-0.4.2.tar.gz
  • Upload date:
  • Size: 79.5 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

Hashes for saidso-0.4.2.tar.gz
Algorithm Hash digest
SHA256 1630f9df5753ad07ecd07c3921864b901e58a3242a7ce4a82623f7c197e74c36
MD5 0f7b6d24290ae86dc6e8b323215f0fb7
BLAKE2b-256 ea1ce227bbcd3110ce84587242f5d9f5e71391e8fffcae9775b7721d7b3e8801

See more details on using hashes here.

File details

Details for the file saidso-0.4.2-py3-none-any.whl.

File metadata

  • Download URL: saidso-0.4.2-py3-none-any.whl
  • Upload date:
  • Size: 53.4 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

Hashes for saidso-0.4.2-py3-none-any.whl
Algorithm Hash digest
SHA256 4ae044e13460f66110737ce39e3bc6691b59e828b4f70337664c9bd1d1e47cbb
MD5 a1075a10058057ab712a2ec9a9eba2ec
BLAKE2b-256 e9dc2f77388646295899679643a55e181076afc86a6692f1bd56bffe4bf43f3d

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page