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

  1. Block — if an argument isn't grounded in the transcript, the function body never runs.
  2. 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.
  3. 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. See examples/openai_tooluse.py.
  • LiveKit / Pipecat / Vapi — keep a Transcript in sync with the session's transcription events and open a call_context. See examples/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, bool arguments are coerced deterministically.
  • Observability. Blocks and errors log under the saidso logger.

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


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.3.0.tar.gz (60.4 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.3.0-py3-none-any.whl (31.1 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: saidso-0.3.0.tar.gz
  • Upload date:
  • Size: 60.4 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.3.0.tar.gz
Algorithm Hash digest
SHA256 3cf288ad7fb2a03a0c7a99687eea205fc9b240302ddc24bf342e8bb53cd685a3
MD5 2d8a09489d01374726a5081dbc34e8d0
BLAKE2b-256 401b8d01c5e75807bb4e5fda667d6d2e2a0be4665a1b6be35d945d3eb57fa999

See more details on using hashes here.

File details

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

File metadata

  • Download URL: saidso-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 31.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.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f1b0c9c20c0868db239ad357517305e19827831b501971ec2e763b483cbe8032
MD5 a877bf9eee4906acbe6810cfe85931cf
BLAKE2b-256 1e05a4de44e9961c2085a1fc22cdffadf04f77d78585f13fb23e3df0b58560da

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