Everything Vigil does with a language model lives here. The layering below is
the point of the package: it is what tells you where a new file goes, and it is
enforced by tests/unit/llm/test_boundary.py and the .importlinter contracts.
The harness boundary
The distinction that matters is loop vs. call. A router call is one
stateless completion. A harness runs many of them, executing tools between
turns. Conflating the two is what made services/ unnavigable.
| Sub-package | Owns | Never does |
|---|---|---|
router/ |
One stateless completion: provider selection, wire-format translation, pre-dispatch sanitization, budget VK resolution | Loop, execute tools, touch SOC domain models |
harness/ |
The multi-turn agent loop: tool execution via MCP, approval gating, conversation state, streaming | Construct SDK clients or pick providers directly — it calls the router |
providers/ |
SDK client construction, the model registry, live model discovery, local Ollama supervision | Know about agents or tools |
cost/ |
Pricing math, pre-call estimation, virtual-key budget enforcement | Call an LLM |
bifrost/ |
The only place that speaks Bifrost’s admin and logging REST APIs | — |
gateway/ |
The ARQ enqueue side in front of the router | Run the jobs it enqueues — that worker is services/worker/ |
security.py (prompt-injection defenses) sits at the top level because both the
router and every harness need it.
Rules
router/must not importharness/. The dependency runs one way.- Nothing under
core/llm/importsbackendordaemonat module scope. Lazy in-function imports of those are the sanctioned escape hatch. One module-scope import is grandfathered by name in the ratchet —harness/ claude.pyreadingbackend.schemas.tool_schemas— and belongs to #414. providers/registry.pyandbifrost/admin.pyimport each other lazily, by design. Do not hoist either import to module scope — it is a real cycle.