Operations #

Running it #

Locally #

uv sync --all-extras --dev
python tasks.py seed
python tasks.py serve          # http://127.0.0.1:8000/docs

With Docker #

docker compose up --build -d
docker compose exec agent support-agent seed
curl -s localhost:8000/readyz | python -m json.tool

Two optional profiles:

docker compose --profile ollama up -d      # add a local model
docker compose --profile postgres up -d    # swap SQLite for PostgreSQL

docker-compose.yml is a local stack, not a production manifest: no TLS, no secret management, no replicas.

A minimum deployment #

AGENT_ENVIRONMENT=production
AGENT_STORAGE__DATABASE_URL=postgresql+psycopg://…
AGENT_SECURITY__REQUIRE_API_KEY=true
AGENT_SECURITY__API_KEYS=<from your secret store>
AGENT_OBSERVABILITY__LOG_FORMAT=json
AGENT_OBSERVABILITY__TRACING_ENABLED=true
AGENT_OBSERVABILITY__OTLP_ENDPOINT=http://collector:4318

Plus, from THREAT-MODEL.md, four things this service does not provide: a possession-factor identity check, rate limiting, append-only audit storage, and TLS termination.

The process refuses to start if any production invariant is unmet, and the error names every problem at once.

Probes #

Probe Use for Behaviour
GET /healthz liveness Reports the process is running. Touches nothing, so a slow dependency cannot cause a restart loop.
GET /readyz readiness Checks the database, the model provider and the tool registry. Reports open circuits and warnings.

Only an unreachable database makes readiness fail. A degraded model or an open circuit produces a warning, because the agent degrades by escalating rather than by stopping — removing it from the load balancer would turn a partial outage into a total one.

{
  "status": "ready",
  "version": "0.1.0",
  "components": [
    {"name": "database", "ready": true, "detail": ""},
    {"name": "composer", "ready": true, "detail": "backend=ollama reachable=true"},
    {"name": "tools", "ready": true, "detail": "registered=6"}
  ],
  "open_circuits": [],
  "warnings": []
}

What to watch #

Alert on these #

Signal Meaning
unverified_claims non-empty on any response The verifier withheld something a model said. Normally zero. A rise means the model is drifting from its evidence.
security.prompt_disclosure in logs A reply repeated the system policy and was withheld.
Escalation rate rising with reason unverifiable_answer The same signal, seen from the other side.
Escalation rate rising with reason budget_exhausted Runs are hitting a limit. Either a dependency slowed down or a plan grew.
open_circuits non-empty for more than a few minutes A tool's dependency is down; those conversations are escalating.
security.injection_in_message rate Not itself a problem — the defence working. A step change is worth looking at.
security.injection_in_tool_output More interesting: something wrote instruction-like text into your data.

Watch, do not alert #

Metrics emitted #

agent.runs_completed (by state and intent), agent.run_latency, agent.steps_used, agent.escalations (by reason), agent.tool_calls (by tool and outcome), agent.tool_latency, agent.verification_failures, agent.injection_findings (by rule and source).

Logs #

Structured JSON, one object per line, with request_id on everything inside a request and session_id on everything inside a run.

Customer message text is never logged. The setting that would allow it is false everywhere, and production refuses to start with it on. Redaction is the final processor, so a field added later cannot bypass it.

Finding one conversation:

docker compose logs agent | grep '"session_id":"conv_5a1f…"'

Answering "what happened?" #

# Which runs escalated, and why
curl -s localhost:8000/v1/runs -H "X-API-Key: $KEY" | python -m json.tool

# Every step of one run, in order
curl -s localhost:8000/v1/runs/run_9c2e…/trace -H "X-API-Key: $KEY" | python -m json.tool

# Who did what
curl -s localhost:8000/v1/audit -H "X-API-Key: $KEY" | python -m json.tool

A trace shows each transition, each tool call with its outcome and duration, each policy decision, the composition, and the verification result — including whether third-party text was neutralised on the way through.

Audit rows name the first eight characters of the acting key's digest, never the key.

Common situations #

Every reply is escalating #

Check /readyz for open circuits, then /v1/runs for the reason. If it is unverifiable_answer, the model is stating things the tools did not return — switching AGENT_CHAT__BACKEND=template restores correct (if plainer) answers immediately while you investigate.

Replies are correct but read like a form letter #

AGENT_CHAT__BACKEND=template, or a configured provider is unreachable and the service has fallen back. /readyz says which.

A customer says the agent did not understand them #

Look at the run: intent will be unknown or the confidence low. The fix is a phrase in agent/intent.py and a scenario in data/scenarios/support.jsonl. Both are data; neither needs a code change beyond adding the line.

A legitimate article stopped being returned #

The relevance floor (_MIN_KNOWLEDGE_SCORE) or an injection rule. Check the logs for security.injection_in_tool_output naming the article. An article about security wording can look like an attack — that is why the scanner requires corroboration and why the suite carries negative controls.

The container starts and immediately fails on import #

uv installs a workspace project in editable mode by default, leaving a .pth pointing at the build directory. The Dockerfile passes --no-editable and sets UV_PROJECT_ENVIRONMENT; if you change either, this is the symptom.

Backup and restore #

The database holds conversations, tickets, runs, traces, audit and feedback. It is the only stateful component.

# SQLite
docker compose exec agent sqlite3 /app/var/agent.db ".backup '/app/var/backup.db'"

# PostgreSQL
pg_dump --format=custom --file=agent.dump "$AGENT_STORAGE__DATABASE_URL"

The knowledge base and the demonstration account are seeded, not authored, so they are reproducible with support-agent seed, which is idempotent.

Upgrading #

The schema is created on startup if missing and is not migrated. For a real deployment, add Alembic before the first schema change — this is stated because create_all on startup is convenient and is not a migration strategy.

Releasing #

CI must be green: lint, types, five test suites, the coverage gate, the scenario gate, the examples, and the container smoke test. Then tag:

git tag -a v0.2.0 -m "…" && git push origin v0.2.0

The container workflow builds, scans with Trivy, smoke-tests the image and publishes it.