Scout comes with three tiers of evaluations, plus a fourth black-box layer for live deployments:
| Tier | Entry point | What it catches | Needs LLM? |
|---|---|---|---|
| Wiring (code-level invariants) | python -m evals wiring |
Scout's tool shape drifts (bare SQL leaks onto Scout, CRM provider loses update_crm, schema guard disappears) |
No |
| Behavioral (cases) | python -m evals |
Scout picks the wrong tool / responses miss expected substrings / forbidden tools fire | Yes |
| Judges (LLM-scored quality) | python -m evals judges |
Answer quality, anything a regex can't express | Yes |
| Live container (black-box probes) | docs/IMPROVE_WITH_CLAUDE.md |
Worktree-isolated improvement loop: probe a live container, find drift, tune scout/instructions.py, commit, repeat. Built to run on /loop. |
(Claude) |
scripts/validate.sh runs ruff + mypy only. Wiring / behavioral / judges are direct python -m evals ... invocations; the LLM-hitting tiers aren't wired into pre-commit. The live-container layer is for hand-running against a deployed instance.
PostgreSQL must be running for every tier, including wiring. The CRM provider builds
SQLTools(db_engine=get_sql_engine(), …)/get_readonly_engine()at module import, and both engines open a connection + bootstrap thescoutschema on first call. Start the DB container (docker compose up -d scout-db) before running any eval tier.
File: evals/wiring.py.
Each invariant is a function that returns None on PASS and raises AssertionError with a diagnostic on FAIL.
W1Scout hasquery_crm+update_crm+list_contexts; no bareSQLToolson Scout (SQL belongs inside the CRM provider).W2DatabaseContextProviderexposes bothquery_crmandupdate_crm;aupdateis overridden.W3The scout engine'sbefore_cursor_executehook rejects DDL/DML againstpublic/ai.W4Every registeredContextProviderhas the expected shape (id/name+query/status/get_tools/instructions).W5GDriveContextProviderusesAllDrivesGoogleDriveTools(the shared-drive-aware subclass).W6MCPContextProviderimplements the lifecycle interface cleanly — exposesquery_mcp_<slug>,acloseis safe pre-connect,status()doesn't raise when unconnected, syncquery()refuses (MCP is async-only).W7Scout sets a sentinel defaultuser_idso callers that don't identify themselves can't leak the{user_id}prompt template into CRM SQL.W8Knowledge wiki exposes bothquery_knowledgeandupdate_knowledge; voice wiki is read-only (update_voicemust not appear).W9scout_followupsships in the canonical DDL — fresh deployments need the table for the closed-loop primitive (cron read ofdue_at <= NOW() AND status = 'pending').
python -m evals wiring # exits 0 on PASS, non-zero on FAILFiles: evals/cases.py, evals/runner.py.
One flat CASES tuple. Fields:
prompt— sent to Scoutexpected_agent— kept for back-compat; with single-agent Scout, leave asNone(runner skips the delegation check)response_contains/response_forbids/response_matches(regex) — deterministic assertionsexpected_tools/forbidden_tools— substring match against tool names Scout called this turnfixture— selects the provider set the case runs against (see table below)max_duration_sfollowups— additional turns in the same session (for memory / multi-turn flows)
Example (from evals/cases.py):
Case(
id="scout_greeting",
prompt="hey",
response_contains=("scout",),
forbidden_tools=("query_", "update_"),
max_duration_s=45,
)python -m evals # in-process agent.arun()
python -m evals --case <id> # single case
python -m evals --verbose # response + tool previewsOn FAIL, the failure reasons are printed inline. Re-run with --case <id> --verbose to drill in.
| Fixture | Provider set |
|---|---|
default |
stub web/slack/gdrive + stub MCP-jira + real CRM |
default_with_fs |
default + filesystem stub for fs-search cases |
injected |
web stub embeds a prompt-injection payload + real CRM |
web_errors / slack_errors / gdrive_errors |
named provider raises on query — graceful degradation cases |
empty_results |
every stub returns empty — no-results handling |
slack_threaded |
Slack stub exposing search_workspace_stub + get_thread_stub for thread-expansion routing |
slack_many_channels |
Slack stub returning 165 channel names — summarize-don't-enumerate cases |
large_gdrive |
Drive stub returning 20 results — curation cases |
wiki |
real wiki backends in a tmp dir — for write→read round-trips |
mcp_unavailable |
MCP stub marked ok=false — graceful degradation |
real |
env-built providers; hits real APIs (use sparingly) |
Add a fixture by extending build_fixture(name) in evals/runner.py.
File: evals/judges.py.
Fields on the Judged dataclass:
prompt— sent to the teamcriteria— the rubric handed toAgentAsJudgeEval. Each bullet is a point budget the judge allocates.scoring—"numeric"(0–10, pass atpassing_score) or"binary"(pass/fail).passing_score— numeric threshold (default7.0).fixture— same shapes as behavioral.max_duration_s— budget for the team run; the judge itself is separate.
python -m evals judges # all judged cases
python -m evals judges --case <id> # one case
python -m evals judges --verbose # responses + judge reason on FAILThe LLM tiers hit OpenAI/Parallel from the host, so .env must be loaded. See CLAUDE.md — Environment loading. Docker picks up .env automatically via docker compose.
Paste docs/EVAL_AND_IMPROVE.md into a fresh Claude Code session. It runs the suite, diagnoses each failure, fixes what's in scope (assertions, prompts, params), and flags what isn't.
Blockers that need human judgment get appended to tmp/flagged.md (gitignored) as ## <case_id> + symptom + why it's out-of-scope. That's the handoff surface — read it when you come back.