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            # or: saidso version
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.1.tar.gz (73.2 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.1-py3-none-any.whl (44.1 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: saidso-0.4.1.tar.gz
  • Upload date:
  • Size: 73.2 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.1.tar.gz
Algorithm Hash digest
SHA256 9269d3e24369621f32fc4532c8388765fcd4324b11cf19457d17eafbfd34d14a
MD5 ee90ee523ff9668e5ec20f11e36fe562
BLAKE2b-256 73eb7677d49f5837327be310ad9e2de8b17c9a6df0873a9a14c3b13ca841173b

See more details on using hashes here.

File details

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

File metadata

  • Download URL: saidso-0.4.1-py3-none-any.whl
  • Upload date:
  • Size: 44.1 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.1-py3-none-any.whl
Algorithm Hash digest
SHA256 899a91e108c79f046bd57179f2db10704e5b3dedd7a60f1a98fadcddfd710ca5
MD5 63d297d40dc250f4d977253201f3009b
BLAKE2b-256 e143afe43ae94cd00c45ccbd5db95c768fb9efa3a79ee57952e309b3c8fab634

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