Design Note: LLM Runner MVP
June 16, 2026 · View on GitHub
- Status: Draft
- Tracking issue: to be filed
- Author: @shangdinggu
- Last updated: 2026-05-08
- Builds on:
0006-resource-ledger.md,0008-agent-sandbox.md,0016-subprocess-agent-runner.md,0017-worker-loop.md
This RFC ships the first real workload on the kernel substrate: a subprocess-based runner that calls an LLM and emits a single response. Everything from RFC 0003 onwards has been infrastructure waiting to be exercised — RFC 0019 is the proof that it works.
The MVP scope is intentionally narrow:
- Single turn. Read init payload (model + system + user message), make ONE provider call, emit response, exit.
- No tool dispatch, no streaming, no permissions. Those are separate, larger pieces. This RFC validates the kernel ↔ LLM contract; tools / streaming / perms compose on top in follow-up RFCs.
- Provider abstraction. A
Provideris a callable(LlmRequest) -> LlmResponse. The shipped providers areMockProvider(for tests) andAnthropicProvider(defensive import of theanthropicSDK; raisesProviderUnavailableif the package is absent). - No touching
providers.py. The existingproviders.pymodule — used by the in-process REPL andagent_runner.py— is not imported. A future RFC may unify the two; this one keeps them independent so the kernel runner doesn't drag in any existing-code BC risk.
The runner ships as a parallel __main__ entry point:
python -m kernel.runner.runner_main # echo runner (existing)
python -m kernel.runner.llm # LLM runner (this RFC)
WorkerLoop / RunnerSupervisor are unchanged — they don't care which runner the supervisor spawns, only that it speaks the JSON-line protocol from RFC 0016.
1. Goals & non-goals
Goals:
- Real LLM calls work end-to-end. Spawn → handshake → provider call → token charge → exit. Verifiable both with mock providers in tests and with real Anthropic keys outside CI.
- Ledger integration. Charges
tokens(input + output combined) andcost_micro(in micro-USD) so RFC 0006 budgets actually bite. - Provider portability. A Provider is a thin protocol; new
providers (OpenAI, Gemini, Ollama) plug in by writing one file
that implements
__call__(LlmRequest) -> LlmResponse. - Defensive imports. Importing
kernel.runner.llmon a machine withoutanthropicinstalled must NOT fail. - Reproducible tests without API keys.
MockProviderreads its response shape from an env var so the subprocess pipeline can be exercised in CI without network or secrets.
Non-goals (this RFC):
- Tool use. Out of scope. Adds permission requests, tool registry coupling, multi-turn loops — substantial complexity.
- Streaming. The chunked output story belongs in a follow-up. v1 buffers and returns the full response in one charge message.
- Multi-turn conversation. One prompt → one response → exit. Multi-turn is a state-machine concern handled by an orchestrator above the runner, not a runner concern.
- Caching. No prompt cache. Anthropic's prompt cache is a great optimisation but adds Provider-specific surface; deferred.
- Vision / files / function tools. Same — out of scope.
- Cost accuracy across pricing tiers. Cost calculation is per-Provider; the MVP uses Anthropic's published per-million rates. Cache discounts, batch discounts, etc., are Provider-specific refinements.
- Replacing
providers.py. That module continues to serve the in-process REPL; no migration in this RFC.
2. Data model
@dataclass(frozen=True)
class LlmRequest:
model: str # provider-specific name
system: str | None # optional system prompt
user: str # the user message
max_tokens: int = 1024
temperature: float = 0.7
metadata: dict = field(default_factory=dict)
@dataclass(frozen=True)
class LlmResponse:
text: str # the model's reply
tokens_input: int
tokens_output: int
cost_micro: int # micro-USD (10⁻⁶ USD)
model: str # echoed from request
finish_reason: str # 'stop' | 'length' | 'error' | provider-specific
metadata: dict = field(default_factory=dict)
3. Provider protocol
A Provider is any callable matching:
def __call__(self, request: LlmRequest) -> LlmResponse: ...
Implementations must:
- Be safe to call from a subprocess (no global state that wedges under fork).
- Raise
ProviderUnavailable(subclass ofRuntimeError) for transient failures (network, rate limit, auth) — the runner maps toexit_kind=failed. - Raise
ProviderInvalidRequest(subclass ofValueError) for malformed inputs — runner maps toexit_kind=failedtoo, but with a different log message. - Honour
max_tokensandtemperature. - Return
cost_microas integer micro-USD, computed from the provider's published rates.
4. JSON-line protocol (init payload shape)
The runner expects init.payload to look like:
{
"model": "claude-opus-4-7",
"system": "You are a helpful assistant.",
"user": "What is 2 + 2?",
"max_tokens": 256,
"temperature": 0.7,
"metadata": { /* opaque to the runner */ }
}
The runner emits, in order:
{ "op": "ready", "pid": 42 }
{ "op": "iteration_start", "iter": 1 }
{ "op": "log", "level": "info", "msg": "calling <model>" }
{ "op": "charge", "dim": "tokens", "amount": 30 }
{ "op": "charge", "dim": "cost_micro", "amount": 250 }
{ "op": "iteration_done", "iter": 1, "tokens": 30, "cost_micro": 250 }
{ "op": "exit", "exit_kind": "completed", "summary": "<truncated text>" }
The supervisor reads charge messages and applies them to the
ledger; the runner emits both charge AND iteration_done (the
latter is informational; the former drives the actual charge —
RFC 0016's auto-charge for tokens/cost from iteration_done is
disabled here to avoid double-counting).
(Implementation note: RFC 0016 §7 "Custom dims" charges via
charge messages but ALSO auto-charges tokens and cost_micro
from iteration_done. To keep this MVP correct, the runner emits
charge messages and a iteration_done with tokens=0,
cost_micro=0 — purely informational. See test
test_no_double_charge.)
5. Provider selection
The runner selects a Provider via the env var
CC_LLM_PROVIDER:
| Value | Provider class | Required setup |
|---|---|---|
mock (default in tests) | MockProvider | CC_LLM_MOCK_RESPONSE_JSON env var |
anthropic | AnthropicProvider | ANTHROPIC_API_KEY env var |
The runner refuses to start if CC_LLM_PROVIDER is unset or
unrecognised; the supervisor sees exit_kind=failed with a clear
stderr tail.
MockProvider reads CC_LLM_MOCK_RESPONSE_JSON (a JSON
LlmResponse). This makes the full subprocess pipeline testable
deterministically without network or API keys.
6. Backwards compatibility
- New file
kernel/runner/llm/__init__.pyand submodules. No file outsidekernel/,tests/,docs/RFC/is touched. - The existing
runner_main.pyis unchanged; tests using it stay green. anthropicis already a project dependency (requirements.txt), but its import inkernel.runner.llm.anthropic_providerhappens lazily — only on first call — sofrom kernel import *works on machines without the SDK.
7. Failure modes
| Failure | Runner behaviour | Supervisor sees |
|---|---|---|
CC_LLM_PROVIDER unset | exit code 2, log error | exit_kind=failed (no exit msg) |
CC_LLM_PROVIDER=anthropic but anthropic not installed | ProviderUnavailable, log error, exit 2 | exit_kind=failed |
ANTHROPIC_API_KEY missing | ProviderUnavailable, log error, exit 2 | exit_kind=failed |
| Provider raises mid-call (rate limit, network) | log, send exit with exit_kind=failed, exit 1 | exit_kind=failed (clean) |
| Provider returns response | normal flow | exit_kind=completed |
Init payload missing model or user | log error, exit 2 | exit_kind=failed |
8. Open questions
- Single-line
chargeaggregation. The runner could emit onechargeper dim (current draft) or batch them in a single message with multiple dims. RFC 0006 doesn't have batch charge. Lean: keep one charge per dim; future RFC may addcharge_manyto scheduler/ledger. finish_reasontaxonomy. The MVP uses'stop' | 'length' | 'error'plus provider-specific extras. Standardising fully is a job for v2.- Should the runner emit
kernel.events.appendcalls for each iteration? Currently no — supervisor'skernel.process.transitionedis enough audit. If a future user wants per-iteration audit, they call append from the runner.
9. Acceptance criteria
A PR claiming this RFC must:
MockProviderconstructed with a frozen response returns it verbatim on every call.LlmRequest/LlmResponseround-trip via dataclass.python -m kernel.runner.llmwithCC_LLM_PROVIDER=mockand a fixedCC_LLM_MOCK_RESPONSE_JSONexits 0, sends ready, sendschargemessages for tokens + cost_micro, sendsexitwithcompleted.- End-to-end via supervisor: spawn the LLM runner with a
tokensledger row, drain → ledger reflects the charged tokens. - End-to-end via WorkerLoop: enqueue an LLM job, worker spawns, ledger gets charged, scheduler entry → completed.
- AnthropicProvider import is lazy: importing
kernel.runner.llmworks without anthropic SDK installed. - CC_LLM_PROVIDER unset → runner exits 2, supervisor sees crashed/failed with non-zero exit code.
- No file outside
kernel/,tests/,docs/RFC/modified.