A complete agent runtime for Rust,
open at every layer.
Providers, agents, tools, storage and telemetry all ship as working implementations, each behind one of seven ports. Substituting your own means implementing that port’s trait and passing it to the builder — the loop itself never changes.
cargo add runably-sdk
Coming soon!
use runably_sdk::{RunablySDK, AgentId, PermissionMode};
// Every seam is optional — bring only what you need.
let sdk = RunablySDK::builder()
.with_provider(anthropic)
.with_toolsets(default_tools())
.with_storage(backend)
.build();
let session = sdk
.create_session(workspace, "refactor".into(), PermissionMode::Default)
.await?;
// Streams tokens, and dispatches each tool the moment its arguments land.
let run = sdk
.send(&session, conversation, AgentId::from("code"), prompt, model)
.await?;
assert!(matches!(run.exit, ExitReason::Completed)); Four layers, one closed boundary
Runably SDK is ports and adapters, taken literally. Dependencies point strictly inward — infrastructure knows about features, features know about the kernel, and the kernel knows about nothing but itself. The SDK facade sits outside all three as the composition root.
publish = false the neutral, SDK-owned domain
the pure loop, its composition root, and multi-agent orchestration
seven leaf crates, each depending inward on runably-models alone
The boundary is enforced, not documented
A consumer names everything through runably_sdk::
and never depends on an inner crate. That is checked in CI by
scripts/lint_sdk_boundary.sh, which greps every
host Cargo.toml for
runably-* dependency keys and fails on any that
is not runably-sdk. The seven port crates are
leaves — each depends inward on runably-models
only — which is what makes them safe to implement without dragging in the engine.
A host supplies only the storage implementation; the runtime derives every role-store from it. It never picks individual stores apart for the host.
let engine = EngineBuilder::new(provider, store.clone())
.with_permission(permission)
.with_tool(tool)
.with_reader(store.clone())
.with_wait_store(store)
.with_config(LoopConfig {
system: "you are a coding agent".into(),
..Default::default()
})
.build(); build() is infallible — everything not supplied
falls back to an inert default. Note that one
Arc<InMemoryStore> satisfies three
different role ports here (MessageWrite,
MessageRead,
WaitStateStore); segregating the ports costs the
implementor nothing and buys every consumer a narrower dependency. Omit
with_reader and
with_wait_store and the engine degrades to a pure
in-memory loop with no durable resume — resume is opt-in by construction.
How contributions compose
The builder collects tools, hooks and event sinks into a registry, then folds them into the
loop's single-object seams at build().
A named bundle of contributions: name() plus register(&mut Registry). A plugin owns what it contributes; the engine owns how the contributions compose.
An ordered mutable bag — add_tool, add_hook, add_event_sink. Insertion order is preserved all the way into the folded wiring.
Folds N LoopHooks into one. The same &mut accumulator is threaded through each in registration order, so hook N observes the mutations of hooks 0..N.
Folds N EventSinks into one, forwarding a clone of every event to every subscriber. Empty degrades to the inert default.
Memory-first, and eager
StreamingLoop owns the authoritative transcript
in RAM and never re-reads storage mid-run — storage is a write-behind sink, not the loop's
working state. It consumes structured streaming tool events, which lets it dispatch a tool the
instant that call's arguments finish arriving, while the rest of the turn is still streaming.
Every call is dispatched the moment its arguments complete. Execution is what gets gated.
The three concurrency-safe calls overlap each other and the stream. The
edit call is dispatched just as eagerly, at
roughly the halfway mark — but it is not concurrency-safe, so it waits for the exclusive
permit and only starts once the safe calls have released theirs.
Dispatch is always eager; execution is gated by a
per-turn tokio::sync::RwLock<()>, fresh
for each turn and shared by every call dispatched in it. A tool that declares
is_concurrency_safe takes a
read permit — many at once, so safe calls genuinely run in
parallel with each other. An unsafe call takes a write
permit, held exclusively for the call's full invocation including its internal auto-retries.
tokio::sync::RwLock is write-preferring, so a
queued writer is never starved by a stream of readers arriving behind it. This was chosen
over holding unsafe calls out of the JoinSet
entirely: the gate is just another await point inside the same spawned future, so it needs
no turn-level draining logic and composes for free with cancellation and abort-on-retry.
- 1 pre_tool hooksthe influence seam runs first, ahead of any policy
- 2 workspace gatea tool requiring a filesystem root is refused in a workspace-less session
- 3 agent tool-policy gatedeny-list, allow-list, then the read-only flag
- 4 permission-resource classificationthe tool maps its own args to a typed resource, once
- 5 PermissionPort::checkallow, deny, ask, or force-reject
- 6 spawn into the turn's JoinSetthe call becomes a task; the stream keeps flowing
Prompt assembly
Tools are built into the prompt on every call and
never cached on the client, so the offered tool set can change from one turn to the next.
model_facing_messages filters out every
Part::Error on the way out: a run's terminal
error is stored so the user can see it, but it is never replayed to the model as if the
model had produced it.
A closed, provider-neutral stream
The LlmClient yields one enum, whatever the
vendor. Each adapter normalises its own wire format into this set at its own boundary.
Early parameters
Tool::on_early_param(key, value) -> EarlyParamEffect
is called once per top-level argument as it parses, before the call is complete. The tool
decides what that key means — which is what lets a UI show "Reading src/main.rs…" while the
rest of the arguments are still arriving, and lets a tool reject a call on a deterministic
policy failure without ever running it.
A pause is an exit, not a suspended future
When a tool needs input — a permission ask, an error retry, a parked subagent — the loop
exits with ExitReason::WaitingForInput and drops
its in-memory transcript. There is no task left parked in memory holding state. The engine
records durable wait tokens keyed by part id; resume reloads the transcript from storage —
the database is truth — and re-enters the pure loop.
Exactly-once, by compare-and-swap
WaitStateStore::try_claim_wait_batch is
all-or-nothing: a concurrent batch observes either every token present or every token
consumed, never half. A caller therefore never holds an already-won token it would have to
compensate for. Resume splits into
claim_resume — the unguarded CAS — and
drive_resume — the guarded loop drive — so a
caller can persist an approval rule only when its answer actually won the race.
Three ways in, one way through
All three entry points funnel into the same private
drive(), so there is exactly one implementation of
the turn loop to reason about.
a fresh run — the user message is the first turn over an empty transcript
add a turn to pre-loaded history; the turn budget is per send
apply answers to paused waits, then continue if any answer advanced the run
Seven seams, seven inert defaults
Each port is a leaf crate with a minimal trait surface and a default implementation that does nothing. You attach only the seams you need, and everything you leave alone stays quietly out of the way rather than forcing a stub.
One model turn as a neutral event stream. Implementations own all provider specifics — wire serialization, auth, retry and backoff, model fallback — and the loop sees none of it. Dropping the stream cancels the request.
What the agent can do, and whether a given call may do it. The tool trait is deliberately tiny; permission and recovery are separate policy seams checked before a call runs and after it fails.
Write-behind sinks plus restart and UI read sources, split fine enough that a component depends only on what it actually uses. One concrete backend implements all eleven.
Observation fan-out. emit is synchronous and non-blocking by design — the loop must never block on a subscriber, so a real sink forwards into a channel and returns at once. SessionEvent is a closed 17-variant enum.
Influence, not observation. Every method takes a &mut accumulator and returns nothing: no short-circuit, no Err. The chain is a deterministic fold in which every hook runs.
A tool asking the user a question mid-run. The implementation owns the whole lifecycle — id assignment, UI projection, reply routing, cancellation; the loop and its tools only ever see the one method.
A typed vocabulary (Component, RunablyScope) plus log_info! / session_span! macros. Logs are native tracing events rendered by the host's process-global subscriber.
Observation and influence are different seams
Most agent frameworks fuse them into one callback list, which means anything that wants to watch a run also has the power to change it. Runably SDK splits them, and the split is what makes a subscriber safe to add.
Events are observation, not influence… many subscribers may watch a run — UI, logging, telemetry — but none can change it.
fn emit(&self, event: SessionEvent) — no
return value, no back-pressure, no await. Fan-out
is unbounded because it is provably harmless: an extra subscriber cannot alter what the run
does, only how slowly its own channel drains.
Hooks are influence, not observation… The contract is three points —pre_tool,post_tool,on_stop— and nothing else.
Three points, deliberately: lifecycle belongs to events, so there is no
on_run_start to reach for. A hook reports trouble
by recording a HookError on the accumulator; a
panic mid-hook is caught by the chain and turned into a fatal one.
A rewrite may not change whether the call pauses the loop, so a rewrite that adds or
removes a WaitForInput is ignored.
Eight methods, five of them optional
Tool carries only what the loop must know to
execute a call safely. UI rendering, MCP metadata and search hints are not on it — those
belong to optional capability traits. Only
name,
spec and
invoke are required; the classification hints
default defensively.
- Allow { updated_args }
- run it, optionally with rewritten arguments
- Deny { message }
- model-facing feedback; the run continues so the model can pick another action
- Ask(WaitForInput)
- escalate to a human; the loop pauses on the wait
- ForceReject { message }
- kill switch — the run ends with an error at the turn boundary
- Retry { option }
- re-invoke, optionally selecting a named retry option
- Continue
- accept the error and carry on
- Fail { reason }
- make the error terminal
- Ask
- escalate; the loop pauses on the wait
use async_trait::async_trait;
use runably_models::{PermissionResource, ToolOutcome, ToolSpec};
use runably_tool::{EarlyParamEffect, Tool, ToolCtx};
use serde_json::{json, Value};
pub struct ReadTool;
#[async_trait]
impl Tool for ReadTool {
fn name(&self) -> &str {
"read"
}
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "read".into(),
description: "Read a UTF-8 file from the workspace.".into(),
input_schema: json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
}),
}
}
// is_concurrency_safe defaults to is_read_only, so this call takes a
// shared read permit and runs alongside its siblings.
fn is_read_only(&self) -> bool {
true
}
fn requires_workspace_root(&self) -> bool {
true
}
// Called as each top-level argument parses, before the call completes.
fn on_early_param(&self, key: &str, value: &Value) -> EarlyParamEffect {
match (key, value.as_str()) {
("path", Some(path)) => EarlyParamEffect::SetDisplay(path.to_string()),
_ => EarlyParamEffect::Ignore,
}
}
fn permission_resource(&self, args: &Value) -> PermissionResource {
match args.get("path").and_then(Value::as_str) {
Some(path) => PermissionResource::read_path(path),
None => PermissionResource::custom("read"),
}
}
async fn invoke(&self, args: Value, ctx: &ToolCtx) -> ToolOutcome {
// Never cwd().unwrap(): the loop's gating keeps a workspace-less call
// from getting here, and this returns a typed error if it ever does.
let root = match ctx.require_cwd() {
Ok(root) => root,
Err(e) => return ToolOutcome::error(e.to_string()),
};
let Some(path) = args.get("path").and_then(Value::as_str) else {
return ToolOutcome::error("`path` is required");
};
match tokio::fs::read_to_string(root.join(path)).await {
Ok(text) => ToolOutcome::success(text),
Err(e) => ToolOutcome::error(format!("read failed: {e}")),
}
}
}
A complete tool, start to finish. Nothing here reaches the engine, the session, or storage —
a leaf tool receives only the narrow per-call
ToolCtx, which is the deliberate opposite of a
fat context object.
File tools with boring guarantees
Five file tools ship in the box. Each one is bounded in memory, cancellation-aware, atomic where it writes, and honest about the point where it stopped. A tool that quietly truncates or silently returns nothing is worse than one that fails, because the model has no way to tell the difference.
Returns a 1-based line-numbered window over a file. The content is streamed line by line and never loaded whole, so a multi-gigabyte log costs the same resident memory as a config file.
- Default window of 2000 lines; offset and limit page through anything larger.
- A 10 MiB retained-byte cap bounds the window regardless of the requested line count.
- A line cut short by the cap is flagged incomplete rather than silently truncated, so the model is never handed a half line that looks whole.
Creates a file or replaces one wholesale. Missing parent directories are created as part of the call.
- Atomic: content lands in a temp file in the same directory, then a rename swaps it in.
- Same-directory staging keeps the rename on one filesystem, so it stays a single atomic operation.
- A concurrent reader observes either the old file or the new one, never a partial write.
Exact substring replacement. There is no fuzzy matching and no similarity threshold: without replace_all, old_string must match exactly once or the call fails.
- Atomic write over the same temp-file-and-rename path as write.
- A process-global per-path lock spans the whole sequence: read, compute, re-read, byte-compare, replace.
- An external mutation landing between the read and the write surfaces "File changed on disk since it was read" instead of clobbering someone else’s edit.
Lists files and directories. Non-recursive by default; supplying a glob switches the walk to recursive and filters as it goes.
- Honours .gitignore, so build output and vendored trees stay out of the context window.
- Caps at 500 entries during the walk with early termination — the cap is a stop condition, not a slice applied afterwards.
- Cancellation is checked on every iteration, so an aborted turn does not leave a walk running.
Regex content search across the workspace. Pure Rust with no external process to shell out to, and the entire walk runs inside spawn_blocking so it never stalls the async runtime.
- Honours .gitignore and caps at 200 matches.
- 500 characters per reported line and 1 MiB read per line, so a minified bundle on one line cannot balloon memory.
- A genuine filesystem error surfaces as an incomplete result carrying the error, never as a silent "no matches".
One contract, two audiences
Every tool returns two things from a single call: a model-facing content string, and a structured, kind-tagged JSON data payload. The model reads the text; your UI reads the payload and renders a diff card, a file
tree or a match list instead of a wall of monospace.
All five declare requires_workspace_root(),
which the loop reads before assembling the tool list. A session without a workspace root
never sees them, and no individual tool re-implements that check or invents its own error
message for it.
Classification never matches on a tool name. Each tool declares its own permission_resource, and proxied MCP tools classify as PermissionResource::Mcp { server, tool } — so a policy can allow one server, one tool on that server, or none, without pattern-matching
strings.
#[async_trait]
impl Tool for ReadTool {
fn name(&self) -> &str { "read" }
/// Declared once, enforced centrally: the loop drops every
/// workspace tool from a session that has no workspace root.
fn requires_workspace_root(&self) -> bool { true }
/// Typed classification. Never a string match on the tool name.
fn permission_resource(&self, args: &ReadArgs) -> PermissionResource {
PermissionResource::FileRead { path: args.path.clone() }
}
async fn execute(&self, ctx: &ToolContext, args: ReadArgs) -> ToolResult {
let window = read_window(ctx, &args).await?; // streamed, 10 MiB retained cap
Ok(ToolOutput::new(window.render_numbered()) // what the model reads
.with_data(ToolData::Read { // what the UI renders
path: args.path,
start_line: window.start,
truncated: window.hit_cap,
}))
}
} The rest of the tool surface
Each toolset is its own crate and its own dependency. Take the ones you want; the loop only
sees the Tool implementations you registered.
A Model Context Protocol client. It connects to MCP servers and proxies their tools in as ordinary runably-tool::Tool implementations, so at the loop boundary an MCP tool is indistinguishable from a native one.
Language-server-backed diagnostics and symbol lookup. The model asks the same compiler front end your editor asks, instead of inferring types from text.
A semantic index over the workspace plus the search tooling that reads it, for the queries a regex cannot express.
Loads skills and invokes them, turning a packaged procedure into something the model can call by name.
The agent tool: one call that hands a scoped task to a child agent. Covered in depth further down.
Thirteen agents, and a way to compose them
The built-ins ship as persisted definitions, not as hardcoded match arms, so an application can edit one, add its own, or narrow the catalog a given caller sees. An agent is a prompt, a model binding and a tool profile — the loop treats all of them identically.
An agent is just another tool
One tool, named agent. A child subagent is a
separate conversation inside the same session, driven through the normal SessionManager::send path — not a parallel execution mode with its own rules. Nesting is bounded by MAX_SUBAGENT_DEPTH = 3.
- 1 Validate the call
The depth guard runs first, then the prompt is checked for emptiness, then the agent id is mapped once into a typed AgentId and checked against the catalog this caller can actually see. Nothing downstream re-parses that id from a string.
- 2 Derive the child conversation id
Deterministically, from the parent conversation plus the tool call id. The same call always addresses the same child, so a replay resolves the existing conversation instead of forking a second one.
- 3 Spawn detached
The child run is fired through the SubagentSpawner seam, detached from the parent task. It is an ordinary run through SessionManager::send — the same streaming, the same persistence, the same inspectability.
- 4 Park the parent
The tool returns immediately with a wait, rather than blocking a task on a child that may take minutes. The parent turn suspends with a typed reason instead of holding a thread.
- 5 Deliver the terminal result
When the child finishes, SubagentBridge delivers its terminal result into the parent’s paused tool call. If that parent is itself parked inside a grandparent, the cascade recurses upwards.
Step four returns ToolOutcome::WaitForInput(WaitForInput::new(WaitKind::AwaitingSubagent, …)).
The wait is typed, so the runtime knows what the turn is waiting on and can report it,
resume it or cancel it without inspecting any strings.
A mapping over variants, never over an error message.
- A failed spawn returns a tool error instead of parking. Parking with no child to deliver a result would strand the parent forever.
- Interrupting a parent tears down its whole child subtree with cancel semantics, so grandchildren are never orphaned behind a parent that no longer exists.
Orchestration in Rust, not in YAML
A workflow is a Rust type implementing Workflow,
statically registered into a WorkflowRegistry.
There is no serialized DSL to learn, no interpreter to debug, and no gap between what the
graph says and what it does: control flow is control flow, and the compiler checks it.
A trait with two members: name(&self) -> &str and async execute(&self, ctx: &WorkflowContext, input: WorkflowInput) -> WorkflowOutcome. Outcomes are Completed { text }, Failed { message } or Interrupted.
The single seam between a workflow and whatever actually runs an agent. Swap in a scripted invoker and an entire orchestration graph is testable without a network call.
Hands the author invoke_agent(), fanout() and descend(), threading depth and cancellation automatically so no workflow has to remember to propagate either.
max_concurrency defaults to 3, max_depth defaults to 3 and is the recursion guard, and an optional per-invocation timeout bounds any single agent call.
Completed(AgentResult { text, usage }), Suspended(SuspendedRun), Failed(AgentFailure) or Interrupted. Suspension is modelled explicitly instead of being folded into success or failure, so orchestration policy can tell "not done yet" from "done".
Merges results as verbatim concatenation under numbered headers. There is deliberately no LLM reducer: an LLM-driven summary is just another invoke_agent call, written by the workflow author who knows what the summary is for.
Fan-out
Concurrent invocations are bounded by a Semaphore, outcomes are ordered by input index, and every slot is filled exactly once — a failure
leaves a recorded failure in its position, never a hole you have to align by hand.
FanoutStatus also carries FanoutStatus::RejectedZeroConcurrency. A budget of zero is rejected outright rather than clamped to one, so a misconfigured
budget surfaces at the call site instead of masquerading as a serial run that quietly costs
three times as long.
use runably_workflow::{
merge_concat, AgentRunOutcome, FanoutPolicy, FanoutStatus, Workflow,
WorkflowContext, WorkflowInput, WorkflowOutcome,
};
/// Three read-only agents inspect the same change, then one agent applies it.
pub struct AuditWorkflow;
#[async_trait]
impl Workflow for AuditWorkflow {
fn name(&self) -> &str { "audit" }
async fn execute(
&self,
ctx: &WorkflowContext,
input: WorkflowInput,
) -> WorkflowOutcome {
// Bounded by WorkflowBudget::max_concurrency. Outcomes come back ordered
// by input index, with every slot filled exactly once.
let fanout = ctx
.fanout(
vec![
(AgentId::from("review"), input.ask("correctness and regressions")),
(AgentId::from("advisor"), input.ask("architectural fit")),
(AgentId::from("explore"), input.ask("blast radius")),
],
FanoutPolicy::BestEffort { minimum_successes: 2 },
)
.await;
// An explicit rejection, not a silent clamp to serial execution.
if matches!(fanout.status, FanoutStatus::RejectedZeroConcurrency) {
return WorkflowOutcome::Failed {
message: "budget allows no concurrent invocations".into(),
};
}
// Verbatim concatenation under numbered headers. Want a summary instead?
// That is one more invoke_agent call, written by you.
let merged = merge_concat(&fanout.outcomes);
match ctx.invoke_agent(AgentId::from("code"), input.apply(merged)).await {
AgentRunOutcome::Completed(result) => {
WorkflowOutcome::Completed { text: result.text }
}
// "Not done yet" is not "done": the suspended run carries its session,
// its conversation and its live waits, so it is genuinely answerable.
AgentRunOutcome::Suspended(run) => WorkflowOutcome::Failed {
message: format!("awaiting input on {}", run.conversation_id),
},
AgentRunOutcome::Failed(failure) => {
WorkflowOutcome::Failed { message: failure.message }
}
AgentRunOutcome::Interrupted => WorkflowOutcome::Interrupted,
}
}
} Sixteen providers, four subscription sign-ins
Provider identity travels as a stable text id over a static provider table, not as an enum. Adding an endpoint is a table row, not a breaking change to a public type, and an id read from a config file that predates the current build still resolves.
Four first-class OAuth flows
A seat someone already pays for is a valid credential. Each flow is implemented against the provider's real behaviour, including the parts that are awkward: fixed ports, forwarder-only redirect URIs, and onboarding calls that must complete before the first request can be billed.
- PKCE with S256 against the Claude Code public client id.
- The user pastes the returned code back into the app; the pasted state is discarded in favour of the state the server tracked, so a doctored paste cannot smuggle in a foreign session.
- Fetches the account UUID after exchange, so usage attributes to the right billing account.
- Tokens refresh automatically.
- Auto-loopback on the fixed port 127.0.0.1:1455, with a 64-byte PKCE verifier.
- The listener binds at the start of the flow rather than the end, so a port conflict fails before the user authorises instead of after.
- The account id is read from the id_token JWT returned with the exchange.
- Tokens refresh automatically.
- access_type=offline plus prompt=consent, so a refresh token is guaranteed rather than hoped for.
- Runs the full Code Assist onboarding: loadCodeAssist, then an onboardUser poll loop until the tier is provisioned.
- Resolves the Cloud project_id that subsequent requests are billed against.
- Tokens refresh automatically.
- Auto-loopback on a random port via the vscode.dev/redirect forwarder.
- GitHub accepts exactly one redirect URI for the VS Code client, so the flow carries the localhost callback URL through the state parameter, behind a validated nonce.
- The nonce is checked on the way back, so the callback cannot be redirected somewhere else.
- No refresh path is needed: the issued token carries no expiry.
Refresh, and who owns which error
OAuthRefresher does single-flight per auth_id with a 300-second skew window, so a burst of concurrent requests produces one refresh, not
one per request. The proactive path refreshes ahead of expiry. The reactive path — the one a
401 triggers — keys on token identity rather than expiry:
if a peer already rotated the credential, it hands back the stored token without touching the
provider.
Mid-run 401s are handled by a RefreshingLlmClient decorator, and the layering rule is strict: no error variant is retried at two layers. The
LLM client retries overload and rate-limit and always propagates Unauthorized; the decorator handles Unauthorized and nothing
else. Retry budgets stay legible because exactly one layer owns each failure.
#[async_trait]
impl LlmClient for RefreshingLlmClient {
async fn send(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
match self.inner.send(req.clone()).await {
// Overload and rate-limit are retried *inside* the client and never
// reach this layer. Unauthorized is always propagated to here.
Err(LlmError::Unauthorized) => {
// Single-flight per auth_id, 300s skew window. The reactive path
// keys on token identity, not expiry: if a peer already rotated
// this token, the stored one comes back without a provider call.
let token = self
.refresher
.refresh_reactive(&self.auth_id, req.token_used())
.await?;
self.inner.send(req.with_token(token)).await
}
result => result,
}
}
} DefaultProviderList fetches the catalog from https://models.dev/api.json and parses it loosely on purpose. One malformed model row is skipped, not fatal — an upstream
typo in a model nobody uses cannot take down the whole picker.
Per-request device, session and machine UIDs are derived by SHA-256 from the AuthId. They are stable across restarts and fingerprint the OAuth credential only, so replaying a
run produces the same identifiers without profiling the host.
Test a full agentic loop, deterministically
Tool dispatch, permission gates, subagent delegation, pause and resume — all of it runs offline and repeatably, behind a 100% line-coverage gate. The SDK’s own cross-crate integration suite is 17 test files and roughly 4,000 lines, and every one of them runs on this mock.
MockLlmClient is a mockito-style
scriptable LlmClient. It is both a
stub — it replays scripted responses — and a
spy — it asserts what the loop actually sent.
use runably_llm_mock::MockLlmClient;
use serde_json::json;
let provider = MockLlmClient::builder()
.turn(|t| { t.reasoning("thinking").tool_call("c1", "read", json!({"path": "x"})); })
.turn(|t| { t.text("done").completed(Some(true)); })
.build(); Two declaration styles, one internal model
.turn(..) appends a sequential turn,
consumed exactly once in registration order.
.when(matcher).reply(..) registers a
matcher rule, optionally capped with
.times(n). On each call the first
stub that both matches and has uses left wins; if none does,
.default_reply(..) fires.
let provider = MockLlmClient::builder()
.when(PromptMatcher::HasToolResult)
.reply(|t| { t.text("after tool").completed(Some(true)); })
.default_reply(|t| {
t.tool_call("c1", "act", json!({})).completed(Some(false));
})
.build(); Matchers read the outbound prompt, so a rule can key off the system prompt, the last user message, the turn index, or the presence of a tool result — which is usually the cleanest way to say “answer differently once the tool has run”.
TurnBuilder emits stream events
A turn is described in the same vocabulary the loop consumes, so a scripted turn and a real provider turn are indistinguishable downstream.
created() opens the turn text(..) assistant text delta reasoning(..) thinking delta tool_call(call_id, name, args) a settled call with complete arguments tool_call_early(call_id, name, key, value, args) surfaces one parameter before the argument stream finishes usage(TokenUsage) token accounting completed(Option<bool>) terminates the turn completed_with_usage(..) terminates and accounts in one event error(..) terminal error event event(StreamEvent) raw escape hatch for anything not covered above Three terminal overrides model the failure modes
Providers fail in more than one shape, and the loop treats each shape differently. Each override pins one of them.
open_error(kind, msg) Fails at stream open.
Nothing is ever committed — the loop sees a dead provider before the first byte.
pending() Opens a stream that never completes.
The shape you need to exercise deadlines and the per-step watchdog.
then_error(kind, msg) Replays accumulated content, then yields a terminal error.
The only path that exercises the loop’s mid-stream continue-retry, because content is already committed when the failure lands.
Assert what the loop sent, not just what it returned
Every outbound prompt is recorded.
Times is
Exactly(n),
AtLeast(n) or
Never.
received() every outbound prompt, in order call_count() how many model turns the loop actually took last_prompt() the most recent prompt as the provider saw it times_matching(&matcher) count of prompts satisfying a matcher verify(&matcher, Times) assertion over that count #[tokio::test]
async fn the_continuation_prompt_carries_the_tool_result() {
let provider = tool_then_text("c1", "act", "done");
let spy = provider.clone();
let engine = engine_with(provider, store, tool, Arc::new(AllowAll));
engine.run(run_params("conv", "go")).await;
// Two model turns: the initial call and the post-tool continuation.
assert_eq!(spy.call_count(), 2);
// Exactly the second prompt includes the tool-result message.
assert!(spy.verify(&PromptMatcher::HasToolResult, Times::Exactly(1)).is_ok());
} verify_protocol_invariants()
An opt-in validator that folds over every recorded outbound prompt and asserts the merged-tool-part protocol. It is wired into the integration harness, so every loop test is pair-integrity-checked automatically — no test has to remember to ask.
- Every Part::Tool is settled — terminal status, output present. This is the dangling tool_use that makes providers return a 400.
- Every call_id is non-empty and unique within its issuing assistant message.
- Tool parts only ever appear on assistant messages, never on user or system ones.
MockLlmClient::from_scenario_json(&str)
A serde Scenario format mirrors the builder for file-driven
runs. Everything except PromptMatcher::Custom and
pending() is expressible in JSON.
from_turns(Vec<Vec<StreamEvent>>) For tests that already hold event vectors and want to hand them over without going through the builder at all.
An agent loop fails in specific ways
Loops spin on the same tool call, hang on a silent stream, duplicate work when a provider drops mid-response, and take the process down when a subscriber panics. Each guardrail below exists because of one of those.
Deterministic k-periodic detection over a bounded ring
A 25-entry ring of call fingerprints is scanned for periods k = 1..5 across five repeats. Fingerprints hash an injective canonical form — tool name plus sorted, length-framed arguments — so key order does not matter and no key/value aliasing is possible.
First detection injects a single
Role::System steering message
and continues — one free recovery.
Second detection hard-stops the run with
ExitReason::Error.
Detection runs on both the normal turn path and the mid-stream-retry turn path, so a retried turn cannot launder a repeat past the counter.
Never restart a turn that already committed content
A retryable error that lands after content was committed cannot be retried inline — that would duplicate text and double-run tools. The loop finalises the partial turn, backs off, and re-runs.
is_mid_stream_retryable is an
exhaustive match with no wildcard arm, so adding an error variant is a compile error rather
than a silent decision to retry it.
Three independent tiers, three different meanings
All three live on LoopConfig
and are Option<Duration>,
so each is off until you set it. They are not layers of the same clock — a hung stream,
a long-but-healthy turn and a runaway tool are distinct failures and produce distinct
exits.
let config = LoopConfig {
total_deadline: Some(Duration::from_secs(300)),
per_step_timeout: Some(Duration::from_secs(30)),
per_tool_timeout: Some(Duration::from_secs(60)),
..Default::default()
}; total_deadline Whole-run wall clock Enforced as a separate sleep_until future in the drive select — deliberately not the cancel token — so a timeout exits with ExitReason::Timeout and is never conflated with Interrupted. A mid-turn fire drops the partial turn whole.
per_step_timeout Idle / progress watchdog Reset on every stream item, so a steadily progressing long turn is never tripped. Expiry becomes a synthetic retryable LlmError::Provider that flows into the existing mid-stream retry path; it is never promoted to a whole-run timeout.
per_tool_timeout Per-call ceiling Wraps each tool inside the held concurrency permit. Expiry records a model-facing ToolOutcome::Aborted and the loop continues.
Cancelling a run tears down everything beneath it
Tokens are derived, not shared, so cancellation is scoped exactly as deep as you cancel.
Run token the whole run, one session-visible handle Per-turn child token derived per model turn Per-call child token handed to each tool invocation A panic never escapes the loop
Embedded in a host application, the loop runs untrusted-ish code on three seams. None of them can unwind into your process.
One crate, one builder, no ceremony
Add the crate, hand the builder a provider, and send a message. Everything else has a default, and every default is inert.
cargo add runably-sdk
Coming soon!
The builder defaults everything
build() is infallible — there
is no configuration error to handle, because there is no required configuration.
an in-memory backend NullResolver SystemClock none, until you opt in with .with_toolsets(..) Session and workspace CRUD works with no LLM configured at all, which is usually how you want to write the first half of your tests.
use runably_sdk::{RunablySDK, AgentId, PermissionMode, SendSegment};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let sdk = RunablySDK::builder()
.with_provider(provider)
.with_toolsets(default_tools())
.build();
let session = sdk
.create_session(workspace, "demo".into(), PermissionMode::Default)
.await?;
let run = sdk
.send(
&session,
sdk.new_conversation_id(),
AgentId::from("code"),
vec![SendSegment::Text { text: "add a test for the parser".into() }],
model,
)
.await?;
println!("{:?}", run.exit);
Ok(())
} How the hexagon is cut: the domain core, the driving side, and the seven ports on the driven side.
Every capability the loop needs, expressed as a trait — and the inert default it ships with.
Script a provider, spy on the prompts, and run the whole loop without opening a socket.