Skip to main content

Crate nachalnik

Crate nachalnik 

Source
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 Capability is a tool’s own declaration, not a verified property; the kernel has nothing to check it against.
  • exec:run subsumes 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 - kamchatka puts its shell tool under Landlock, which turns network: deny into a refused TCP connect syscall.
  • 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:

traityou providethe kernel provides
Providera model, however you reach itthe request, verbatim
Toolwhat the model can dothe schema, the gating, the recording
PermissionPolicywhat is allowedthe question, and the refusal
Projectorthe shape of a requestthe context it is projected from
TokenCounterhow tokens are countedevery number it reports, and what each request really cost
Compactorwhat to drop when it gets fullthe veto on pinned items, and the report

§How to use it

  1. create a Kernel with a Config
  2. give it a Provider, and whichever Tools and PermissionPolicy you want
  3. Kernel::push context and Kernel::step (or Kernel::turn) the loop
  4. read Kernel::subscribe to see what is happening, and Kernel::items plus Kernel::budget to 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

  • Context is a list of identified ContextItems. 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: unless Config::keep_truncated_output is turned off, the whole of what a tool said is kept beside the truncated copy the model is shown. ContextState::Elided is 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_blocks decides which shape the next request goes out in, and flattening says in Projection::repairs where it cost something.
  • Projection is what the context turns into on the wire, complete with what was left out and why.
  • Event is everything that happens - including every state transition - broadcast live and recorded in an append-only Session log 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. Calibrating closes 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 returning 0: TokenCounter::uncounted rides up to Budget::uncounted and ContextItem::uncounted, so a figure that is a floor is never mistaken for a complete one. A Content::Blob is the usual case, because what a picture costs is a formula over its dimensions and every vendor publishes a different one. Blob::meta is where a counter gets the inputs; examples/pricing_a_picture.rs is one written out.
  • Snapshot is where it all ended up, which is a different question: Kernel::snapshot and Kernel::resume carry 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 ToolCallId is durable. It survives a Snapshot, and Snapshot::used_calls means 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 inside invoke has minted one that dies with the process.
  • A call with no result is findable: the assistant item holds the call and no ContextKind::ToolResult answers it. Which of the two worlds it is - the effect committed, or it never happened - only the external system can say.
  • Returning ToolOutput::error is 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 to ContextIds a client can show before acting on them.
  • test: a scripted Provider, a few dummy Tools, off-the-shelf permission policies and a mechanical Compactor, for testing an agent without a network.

Modules§

selectors
A small language for naming context items, behind the selectors feature.
test
Helpers for testing an agent without a model provider, enabled by the test feature.

Structs§

AskAlways
A PermissionPolicy that 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.
BytesPerToken
A TokenCounter that divides the byte length of the content by a fixed number.
Calibrating
A TokenCounter that corrects another one against what providers actually charge.
Calibration
What a Calibrating counter has learned, and what it is derived from.
Capability
What a Tool does, as one operation in one domain: fs:read, context:revise.
CompactionPlan
What a Compactor proposes doing about a Budget.
CompactionReport
Exactly what a compaction pass did.
Config
The kernel’s configuration. See the source of Config::default for the defaults.
Context
The set of context items, in the order they were added.
ContextId
The identifier of a ContextItem.
ContextItem
A single, identifiable piece of context.
DeltaSink
The channel a Provider reports streaming fragments through.
Kernel
The agent runtime: a state machine, a context, and nothing else.
LinearProjector
The default Projector: one message per item, in insertion order.
Message
A single message in a ModelRequest.
ModelInfo
The identity and capabilities of the model behind a Provider, as reported by it.
ModelRequest
A request to a model: exactly what will be sent, and nothing else.
ModelResponse
A model’s answer to a ModelRequest.
OutputSink
The channel a Tool reports 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.
PermissionId
The identifier of a permission request, used to answer it.
PermissionRequest
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 Kernel needs in order to carry on where another left off.
StateChange
What a Kernel::set_state did, item by item.
TooLong
A request the model refused to read, because it was longer than the model can take.
ToolCall
A tool invocation requested by the model.
ToolCallId
The identifier a provider assigns to a tool call, used to match a result to its call.
ToolOutput
What a Tool produced.
ToolSpec
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.
ContextKind
What a ContextItem is, in terms of the model protocol.
ContextState
Whether, and how, an item takes part in the next request. note: three of these - ContextState::Excluded, ContextState::Archived and ContextState::Superseded - are one behaviour under three words. Nothing in this crate branches on which of them an item is in: ContextState::is_projected groups them, they are equally absent from the request, they take a tool call down with them alike, and every one of them is restorable by Kernel::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::Elided is 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.
GrantSource
Where a Grant came from.
Role
The role a Message is attributed to.
State
What the runtime is doing, and therefore what it will do next.
StopReason
Why the model stopped producing output.
Verdict
A PermissionPolicy’s answer about a tool call.

Traits§

Compactor
Optional, and optionally automatic, context management.
PermissionPolicy
Decides whether a tool call may run.
Projector
Turns context items into the messages of a request.
Provider
A source of model responses.
TokenCounter
Turns content into a token count.
Tool
Something the model can invoke.

Type Aliases§

BoxError
An error produced by user-supplied code (a Provider or a Tool) 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.

Attribute Macros§

async_trait