Expand description
nachalnik is a small, honest agent runtime: an execution loop around a language model in which the context, the tools, the permissions and the requests are explicit, inspectable state rather than hidden behaviour.
The agent is not the boss. You are.
§The loop is a state machine
Idle ── step ──> Requesting ──(no tool calls)──> Finished
Ready │
▲ ├──(calls, all decided)──> Ready ── step ──> Executing ──> Idle
│ │
└── decide ── Deciding <──(calls, one to ask about)Kernel::step performs exactly one of those transitions and returns the State it
produced; Kernel::turn repeats until the model ends its turn or somebody has to decide
something. Finished carries the model’s own StopReason, because a turn that ran out of
output tokens is not the same thing as one that ended. State::Requesting and
State::Executing mean the loop is already being driven, and a second Kernel::step is
Error::Busy rather than a second request.
Every other state is a resting state, and whatever you change while the kernel rests is what
the next request will contain. State::Ready exists for exactly that reason: the model has
said which tools it wants, nothing has run yet, and you can look first.
Kernel::interrupt can be called from anywhere and stops the loop before the next
transition. Stopping something already in flight is cooperative, because the kernel owns
neither the socket nor the future: a Provider that checks DeltaSink::is_interrupted
and a Tool that checks OutputSink::is_interrupted can hand back what they have, and
that is recorded like any other turn.
§What it does not do
There is no system prompt in this crate. No instructions, no personality, no planning
ritual, no “think step by step”, no default tools, no filesystem access, no process
spawning, no HTTP client, no subagents, no MCP, no background activity, no /context
renderer, no permission table, and no automatic context management unless you install a
Compactor - which then reports everything it did.
§What it does not protect you from
There is no sandbox here, and there is not going to be one in this crate.
The kernel executes nothing: no filesystem code, no network code, no process spawning. Every
side effect in a session happens inside a Tool you wrote and registered, so there is
nothing here to contain, and containment - a jail, a namespace, seccomp, a container - goes
inside your tool or around the whole process.
What it does enforce is one thing: a call the PermissionPolicy refused is never handed to
Tool::invoke, and the refusal is recorded as an Event and as a tool result the model is
told about. That is a decision point with a paper trail, not a boundary:
- A
Capabilityis a tool’s own declaration, not a verified property; the kernel has nothing to check it against. exec:runsubsumes every other capability, so a policy that allows it has allowed all of them, whatever it answers about the rest.- A policy that reads a command’s text is a heuristic: it can make a refusal real for what was
written, not for a program that reaches the network some other way. Confinement that can
stop that belongs where the process is spawned -
kamchatkaputs itsshelltool under Landlock, which turnsnetwork: denyinto a refused TCPconnectsyscall. - Anything in the context is something the model reads, and it can carry instructions. What this runtime offers against that is the policy - which nothing in a model’s output reaches except as a tool name and arguments - and a context you can see before the request goes.
§The parts are yours
The kernel does not own a UI, an editor, a model, or a tool. Those are your side of the interface:
| trait | you provide | the kernel provides |
|---|---|---|
Provider | a model, however you reach it | the request, verbatim |
Tool | what the model can do | the schema, the gating, the recording |
PermissionPolicy | what is allowed | the question, and the refusal |
Projector | the shape of a request | the context it is projected from |
TokenCounter | how tokens are counted | every number it reports, and what each request really cost |
Compactor | what to drop when it gets full | the veto on pinned items, and the report |
§How to use it
- create a
Kernelwith aConfig - give it a
Provider, and whicheverTools andPermissionPolicyyou want Kernel::pushcontext andKernel::step(orKernel::turn) the loop- read
Kernel::subscribeto see what is happening, andKernel::itemsplusKernel::budgetto see what the next request will cost
use std::sync::Arc;
use nachalnik::{
async_trait, BoxError, Config, ContextItem, ContextState, DeltaSink, Kernel, ModelInfo,
ModelRequest, ModelResponse, Provider, State, StopReason,
};
// a provider is anything that can answer a request
struct Parrot;
#[async_trait]
impl Provider for Parrot {
fn info(&self) -> ModelInfo {
ModelInfo {
context_limit: Some(8_192),
..ModelInfo::new("example", "parrot")
}
}
async fn respond(
&self,
request: ModelRequest,
_deltas: DeltaSink,
) -> Result<ModelResponse, BoxError> {
let last = request.messages.last().and_then(|m| m.content.clone());
Ok(ModelResponse::text(last.unwrap_or_default().to_text().into_owned()))
}
}
let kernel = Kernel::new(Config::default());
kernel.set_provider(Arc::new(Parrot));
// context is added explicitly, and every item can be named afterwards
let file = kernel.push(ContextItem::file("src/parser.rs", "fn parse() {}").pinned());
kernel.push(ContextItem::user("why is this failing?"));
// exactly what is about to be sent, before it is sent
assert_eq!(kernel.preview_request()?.messages.len(), 2);
assert_eq!(kernel.state(), State::Idle);
// the loop
let State::Finished { item, stop } = kernel.turn().await? else {
panic!("nothing needed deciding")
};
assert!(kernel.item(item).is_some());
assert_eq!(stop, StopReason::EndTurn);
// and the context remains yours
kernel.set_state([file], ContextState::Excluded, Some("too big".into()));
assert!(kernel.undo().unwrap());§Where the state lives
Contextis a list of identifiedContextItems. Nothing is ever silently dropped: removal is a state change (Kernel::set_state), so a removed item can still be listed, inspected, and restored. That holds for an output limit too: unlessConfig::keep_truncated_outputis turned off, the whole of what a tool said is kept beside the truncated copy the model is shown.ContextState::Elidedis a third answer, between in and out: the item stays in the request as a short marker. A tool result can then stop costing what it holds and still answer the call that asked for it, where an excluded one takes that call out of the request with it.- An assistant turn is recorded the way the model produced it. Where a provider reports a
content slot, a reasoning slot and a list of calls, that is what the item holds; where it
reports an ordered sequence - thinking, a sentence, a call, more thinking before the next
one - the item holds
Content::Blocks, because the order is part of the message and a context that flattened it on the way in could never project it back out.LinearProjector::send_blocksdecides which shape the next request goes out in, and flattening says inProjection::repairswhere it cost something. Projectionis what the context turns into on the wire, complete with what was left out and why.Eventis everything that happens - including every state transition - broadcast live and recorded in an append-onlySessionlog that survives changes to the client and the model.- The token figures are a
TokenCounter’s, and the default one is an estimate that comes out low.Calibratingcloses the loop instead of guessing better: the kernel reports what a request was estimated at beside what the provider charged for it, and the counter corrects itself from the first response large enough to learn from. Where a counter cannot reach something at all it says so rather than returning0:TokenCounter::uncountedrides up toBudget::uncountedandContextItem::uncounted, so a figure that is a floor is never mistaken for a complete one. AContent::Blobis the usual case, because what a picture costs is a formula over its dimensions and every vendor publishes a different one.Blob::metais where a counter gets the inputs;examples/pricing_a_picture.rsis one written out. Snapshotis where it all ended up, which is a different question:Kernel::snapshotandKernel::resumecarry a session across processes, because a log of events that name their items cannot rebuild the items.
§Surviving a crash
What a snapshot restores is state, not the world. The kernel can tell you, afterwards, which call the model asked for and never got an answer to; it cannot tell you whether the thing that call was going to do got done, because it did not do it. That half is the application’s, and this is the seam:
- A
ToolCallIdis durable. It survives aSnapshot, andSnapshot::used_callsmeans a resumed session refuses to issue it again - so the call already has a name that outlives the process, and an external operation keyed on it is one an application can go back and ask about. A tool that mints an identifier insideinvokehas minted one that dies with the process. - A call with no result is findable: the assistant item holds the call and no
ContextKind::ToolResultanswers it. Which of the two worlds it is - the effect committed, or it never happened - only the external system can say. - Returning
ToolOutput::erroris not the crash case. That is an answer: the failure is recorded and the model reads it. The unanswered case is a process that died with the call in flight.
Checkpointing is two writes, and their order decides what a crash can cost. Copy, write, drop,
then snapshot: Kernel::history_since hands back clones and leaves the kernel holding them,
Kernel::drain_history hands back the only copy there is. Draining before writing opens a
window in which the records are nowhere at all. The snapshot goes last for the same reason
turned round: a snapshot ahead of the log is a state nothing accounts for, while a snapshot
behind it is a state the log can explain. tests/crash.rs is this written out and checked.
§Features
Both are off by default, because neither is part of the runtime:
selectors:selectors::Selector, a small language for naming context items (17,tool:grep:latest,all:tool_results,file:src/foo.rs) that resolves toContextIds a client can show before acting on them.test: a scriptedProvider, a few dummyTools, off-the-shelf permission policies and a mechanicalCompactor, for testing an agent without a network.
Modules§
- selectors
- A small language for naming context items, behind the
selectorsfeature. - test
- Helpers for testing an agent without a model provider, enabled by the
testfeature.
Structs§
- AskAlways
- A
PermissionPolicythat asks about everything. - Blob
- Bytes that are not text, in the form every one of these APIs wants them.
- Budget
- How much room the context is taking up, and how much there is.
- Bytes
PerToken - A
TokenCounterthat divides the byte length of the content by a fixed number. - Calibrating
- A
TokenCounterthat corrects another one against what providers actually charge. - Calibration
- What a
Calibratingcounter has learned, and what it is derived from. - Capability
- What a
Tooldoes, as one operation in one domain:fs:read,context:revise. - Compaction
Plan - What a
Compactorproposes doing about aBudget. - Compaction
Report - Exactly what a compaction pass did.
- Config
- The kernel’s configuration. See the source of
Config::defaultfor the defaults. - Context
- The set of context items, in the order they were added.
- Context
Id - The identifier of a
ContextItem. - Context
Item - A single, identifiable piece of context.
- Delta
Sink - The channel a
Providerreports streaming fragments through. - Kernel
- The agent runtime: a state machine, a context, and nothing else.
- Linear
Projector - The default
Projector: one message per item, in insertion order. - Message
- A single message in a
ModelRequest. - Model
Info - The identity and capabilities of the model behind a
Provider, as reported by it. - Model
Request - A request to a model: exactly what will be sent, and nothing else.
- Model
Response - A model’s answer to a
ModelRequest. - Output
Sink - The channel a
Toolreports its progress through while it is still running. - Overrun
- How long a request was, against the length the model would take.
- Part
- Something the model produced, and whatever the provider attached to it.
- Permission
Id - The identifier of a permission request, used to answer it.
- Permission
Request - Everything known about a tool call at the moment permission for it is considered.
- Projection
- The messages a context projects to, plus the paper trail of how they came about.
- Record
- A single entry in a session’s history.
- Removed
- An item a compaction pass removed.
- Session
- An append-only history of everything that happened in a session.
- Skipped
- An item that did not make it into the request, and why.
- Snapshot
- Everything a new
Kernelneeds in order to carry on where another left off. - State
Change - What a
Kernel::set_statedid, item by item. - TooLong
- A request the model refused to read, because it was longer than the model can take.
- Tool
Call - A tool invocation requested by the model.
- Tool
Call Id - The identifier a provider assigns to a tool call, used to match a result to its call.
- Tool
Output - What a
Toolproduced. - Tool
Spec - A tool’s definition: its stable identity, its schema, and the capabilities it needs.
- Usage
- The token counts a provider reported for a request.
Enums§
- Block
- One piece of an assistant turn, in the position the model produced it in.
- Content
- A piece of content: plain text, structured data, or an ordered sequence of
Blocks. - Context
Kind - What a
ContextItemis, in terms of the model protocol. - Context
State - Whether, and how, an item takes part in the next request.
note: three of these -
ContextState::Excluded,ContextState::ArchivedandContextState::Superseded- are one behaviour under three words. Nothing in this crate branches on which of them an item is in:ContextState::is_projectedgroups them, they are equally absent from the request, they take a tool call down with them alike, and every one of them is restorable byKernel::set_state. What differs is what a reader is told, which is worth having and is not a rule: a client that lists a context shows the word, and a selector picks on it.ContextState::Elidedis the one distinction here that the projector actually makes; its note says how. - Delta
- A fragment of a streamed model response.
- Domain
- The family of side effect an operation belongs to: what a rule is written about.
- Error
- Things that can go wrong in the kernel itself.
- Event
- Everything the kernel does, as it happens.
- Grant
- A resolved permission: the answer a tool call is actually executed (or not) under.
- Grant
Source - Where a
Grantcame from. - Role
- The role a
Messageis attributed to. - State
- What the runtime is doing, and therefore what it will do next.
- Stop
Reason - Why the model stopped producing output.
- Verdict
- A
PermissionPolicy’s answer about a tool call.
Traits§
- Compactor
- Optional, and optionally automatic, context management.
- Permission
Policy - Decides whether a tool call may run.
- Projector
- Turns context items into the messages of a request.
- Provider
- A source of model responses.
- Token
Counter - Turns content into a token count.
- Tool
- Something the model can invoke.
Type Aliases§
- BoxError
- An error produced by user-supplied code (a
Provideror aTool) and carried by the kernel without being interpreted. - Params
- The knobs sent to the provider alongside the messages, in whatever shape that provider understands.
- Result
- The result type used by the kernel.