- Rust 90.4%
- Python 9.4%
- Shell 0.2%
| .cargo | ||
| crates | ||
| docs | ||
| examples | ||
| scripts | ||
| src | ||
| tests | ||
| .gitattributes | ||
| .gitignore | ||
| AGENTS.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| deny.toml | ||
| LICENSE | ||
| README.md | ||
nyatibara
Nyatibara is a small, sound, intuitive, opinionated Rust framework for evolving LLM context and composing agents. Its primary design axis is to be beautiful, simple, and intuitive without becoming rigid: soundness is the floor, and flexibility comes from a few explicit composition seams rather than speculative layers. Under that rule, its two functional directions are a middleware-like context pipeline—including cache-friendly background observation and compaction—and dynamically created, independently threaded agents that collaborate through messages.
The current reviewed design target is indexed by docs/design.md, and docs/status.md keeps that target separate from implemented behavior. The repository currently implements stable Message/Checkpoint identities, a sealed atomic WorldBackend SPI with memory, PostgreSQL, and libSQL/Turso adapters, acknowledged-only reopen semantics, ordered ephemeral Transformation proposals, a provider-neutral Model/Agent path, concurrent Tool waves with explicit approval, continuation-bound Waiting/Blocked outcomes, exact-call reconciliation, and an optional addressed Teams lifecycle crate.
Design/implementation split: the API guide below documents executable foundation behavior at the current checkout. It is not a claim of conformance to the current design. Historical v4 documents and tests remain provenance for that earlier program, not authority over the human golden rule or current reviewed design.
The default openai feature includes the HTTP transport and adapter. cargo check --no-default-features --lib removes that transport and adapter—including reqwest—while retaining the shared history, model-call vocabulary, and agent core. This 0.1 package is deliberately marked publish = false; no public license is granted and the current review target is the repository API, not a crates.io release. See the repository-root LICENSE file.
Start with five core objects
WorldStore retains immutable Messages and Checkpoints
Message is one immutable semantic history item
Checkpoint names one immutable ordered list of unique MessageIds
Thread selects one Checkpoint in one WorldStore
Agent owns one Thread and evolves it through Transformations, Model, and Tools
Model and Tool are provider-neutral behavior seams, not durable core objects.
One chat follows this path:
user input → canonical Thread → ephemeral MessageList
│ ordered async Transformations
└─ one adopted Checkpoint
→ exact ModelRequest → terminal answer
The accepted user input becomes canonical first. Before every model invocation, ordered Transformations pass one ephemeral MessageList along without committing intermediate proposals. The Agent adopts the final complete list at most once, then ModelRequest::checkpoint_id() and message_ids() name that exact canonical input. For a Tool wave, the complete request batch becomes canonical before execution. Automatic mode polls every simultaneously ready phase/dependency stratum concurrently; Manual mode hands invocation and await order to the caller. All known results become canonical once in the retained Automatic or caller-supplied Manual publication order. An unknown effect leaves the request checkpoint selected without a partial result batch and blocks further chat through the current process-local reconciliation journal. Each Agent owns one canonical Thread, and Agent::chat(&mut self, ...) serializes operations on that Agent. Use build() for a fresh Thread or build_on(thread) to transfer an existing one.
Choose an entry point
| Goal | Start with |
|---|---|
| Run a conversation with local tools | Agent, AgentTool, Thread |
| Run one bounded inline child from a parent tool | ToolCall::model_checkpoint_id, AgentBuilder::run_child_on |
| Evolve the exact model-facing history | AgentBuilder::context, ContextTransform, MessageList |
| Send one raw Responses request | OpenAiResponses, ResponseRequest |
| Maintain or fork canonical history | Message, Thread, WorldStore |
| Supply persistent immutable storage | WorldBackend, SealedCommit, WorldStore::with_backend |
| Implement a replay-free provider adapter | the provider-neutral agent::Model trait |
Agent quick start
use std::{env, sync::Arc};
# #[cfg(feature = "openai")]
use nyatibara::{
agent::Agent,
openai::{OpenAiModel, OpenAiResponses},
};
# #[cfg(feature = "openai")]
# async fn example() -> Result<(), Box<dyn std::error::Error>> {
let client = Arc::new(OpenAiResponses::new(
env::var("NYATIBARA_BASE_URL").unwrap_or_else(|_| "https://api.openai.com/v1".into()),
env::var("OPENAI_API_KEY").ok(),
)?);
let model = OpenAiModel::new(
env::var("NYATIBARA_MODEL").unwrap_or_else(|_| "gpt-5.6".into()),
client,
)?;
let mut agent = Agent::builder(model).build()?;
let turn = agent
.chat("Explain ownership in one sentence.")
.await?
.into_completed()
.expect("this model-only example cannot suspend");
println!("{}", turn.text());
# Ok(())
# }
Evolve one exact model input
A context transform consumes one ephemeral MessageList and returns either
TransformOutcome::Unchanged or one complete proposal. Existing entries preserve
canonical identity. Inserted application content remains a draft until the full ordered
chain succeeds, when the Agent adopts at most one successor Checkpoint:
use nyatibara::{
agent::{Agent, ContextError, Model, TransformOutcome},
message::Message,
thread::MessageList,
};
# fn build(model: impl Model) -> Result<(), Box<dyn std::error::Error>> {
let agent = Agent::builder(model)
.context(|mut context: MessageList| async move {
context
.insert(0, Message::developer("Answer from the supplied context only."))
.map_err(ContextError::from)?;
Ok(TransformOutcome::Proposed(context))
})
.build()?;
# let _ = agent;
# Ok(())
# }
Transforms run in registration order before every model invocation, including the invocation after a known local Tool response. They may do asynchronous computation and retain plugin-local state, but ordinary application transforms must not perform external domain effects. A proposal may only add application-authored content; Model-, Tool-, and provider-owned authorship is protected by admission. Failures and panics stop before the model call without committing intermediate proposals; if an earlier Tool effect exists, its reconciliation evidence is preserved. Panic polling and destructor unwinds are contained, but Rust's process panic hook runs before containment and may observe the payload, so transforms must never panic with secrets.
Built-in orthodox compression
agent::orthodox_compression::OrthodoxCompression is the supplied conventional
pre-compaction policy. It keeps the latest two messages and replaces the older prefix with one
developer summary from a separately configured, Tool-free Model request. The retained suffix is
expanded when needed so a Tool request never becomes separated from its correlated responses.
The separate summary call is intentionally model-only: a bad, blank, non-text, or Tool-requesting
summary response fails the Transformation and leaves canonical history unchanged.
When compression is an availability-neutral optimization, append .best_effort() to keep the
original history if the summary model returns an unusable response.
If static instructions must remain verbatim, add them to an explicit protected prefix. They do not replace the two-message recent tail, and the generated summary remains outside that prefix so the next compaction refreshes it rather than accumulating summaries.
use nyatibara::agent::{Agent, orthodox_compression::OrthodoxCompression};
# fn build<M: nyatibara::agent::Model + Clone + 'static>(main: M, summarizer: M) {
let agent = Agent::builder(main)
.context(OrthodoxCompression::new(summarizer).preserve_prefix(1))
.build();
# let _ = agent;
# }
Run one inline child Agent
A structurally validated ToolCall carries a restricted single-use capability for the
exact transformed checkpoint that the model saw before it emitted that call.
ToolCall::model_checkpoint_id() names that process-local seed, and
AgentBuilder::run_child_on(call, input) consumes it to run one internal Agent at exactly
that checkpoint. The internal Agent and its mutable Thread never escape the method, and
the spawn request is absent from inherited history.
ToolCall clones share the same capability: taking the context through one clone
invalidates every other clone with AgentBuildError::ChildContextConsumed. Retaining an
unconsumed call therefore retains its process-local WorldStore; consume or drop it
promptly. A Thread carries history, not Tool authority. Child Tool authority is empty by default. A parent delegation Tool may seal an explicit
subset with AgentTool::allow_child_tools, and run_child_on rejects every child Tool outside
that subset. Durable or nested Agent lifecycle belongs to the optional Teams integration
rather than history metadata.
Directly await run_child_on inside the parent handler and convert its typed Turn into
one bounded ToolOutput. The parent runtime then creates exactly one fresh correlated tool
response; it never merges the child's transcript into parent history. Dropping that
directly awaited parent chat drops the internal child future and leaves conservative parent
effect evidence. The framework does
not provide detached execution, stop handles, persistent identities, Teams, or restart
recovery; application code can still move any owned future into its own executor. See
tests/child_agent.rs for the network-free end-to-end contract.
examples/agent.rs connects the runtime to OpenAI and adds a stateful async local tool. examples/offline_tool_turn.rs runs the complete model → tool → model → publish path without a network or API key. Register with AgentTool::always, AgentTool::denied, or AgentTool::requires_human; a human-gated whole wave starts no handler until its exact continuation is approved. Automatic execution can order ready concurrent strata with AgentTool::phase and after_tools. ToolExecutionMode::Manual instead returns a caller-owned wave: claim and poll selected async handlers, skip others, seal the complete publication order, then resume with the typed Tool-execution continuation.
In-source consumer applications
examples/nyatichat: an independent Dioxus + PostgreSQL reference chat workspace. It keeps deterministic recovery gates and adds an opt-in OpenAI Responses-compatible live model path.Nyatibookis the reserved name for the future document-to-book consumer; no implementation is claimed yet.
Nyatichat is an independent nested Cargo workspace that consumes the enclosing Nyatibara package by path. Run its documented gates from examples/nyatichat; root cargo test intentionally covers the framework only.
Failure boundary at a glance
| Failure point | Canonical history | Caller action |
|---|---|---|
| Model adapter/panic/protocol failure before request publication | latest acknowledged input/Transformation checkpoint | inspect the typed error |
| Deterministic whole-wave rejection before Tool dispatch | canonical request batch followed by one complete correlated rejection batch; no handler starts | Model continues from the closed wave |
| Known Tool failure, then terminal answer | complete published turn, including a fixed model-safe Tool response | consume Turn |
| Human-gated Tool wave | canonical complete request batch; no handler starts | inspect Waiting::approval_wave, then call approve or deny with its continuation |
| Manual Tool handoff | canonical complete request batch; no handler starts automatically | inspect Waiting::manual_tool_wave, invoke or skip calls, finish the order, then call resume_tool_execution; use abort_tool_execution if the wave/report was lost |
| Unknown or interrupted effect | canonical complete request batch; no partial result batch | inspect BlockedWave, obtain authoritative finals, then call reconcile |
| Later Model/protocol/storage failure after known Tool outcomes | latest acknowledged checkpoint, including any already adopted request/result history | inspect the cause and retained outcomes across waves |
| Successful terminal answer | complete Model/Tool turn | consume Turn |
An unresolved effect-bearing wave blocks later chats on the same Agent; there is no abandon-and-continue API. blocked_wave() is read-only, and only reconcile with exact authoritative finals closes the wave. Acknowledged semantic CallId values and Agent-level provider correlations remain separate per-Agent tombstones. A sealed provider binding may additionally retain shared claims across cloned models. All are process-local safeguards, not durable idempotency or exactly-once guarantees.
Read Agent runtime: model, tools, publication, and reconciliation for the complete execution and failure contract. A deterministic reconciliation example is in examples/reconciliation.rs.
One raw Responses call
# #[cfg(feature = "openai")]
use nyatibara::{openai::OpenAiResponses, response::ResponseRequest};
# #[cfg(feature = "openai")]
# async fn example() -> Result<(), Box<dyn std::error::Error>> {
let client = OpenAiResponses::new("https://api.openai.com/v1", std::env::var("OPENAI_API_KEY").ok())?;
let response = client
.create(ResponseRequest::text(
"gpt-5.6",
"Explain ownership in one sentence.",
))
.await?;
println!("{}", response.text());
# Ok(())
# }
The complete executable is examples/simple.rs.
Request and Items
The public model follows the Responses API directly:
ResponseRequest
├─ model
├─ input: Vec<Item>
│ ├─ Message { role, parts }
│ ├─ FunctionCall
│ ├─ FunctionCallOutput
│ ├─ Reasoning
│ └─ Provider
├─ tools / tool_choice / parallel_tool_calls
├─ max_output_tokens / temperature / top_p
└─ extra
use nyatibara::response::{
File, FunctionCall, FunctionCallOutput, Item, Message, Part,
ResponseRequest, Source, ToolOutput,
};
use serde_json::json;
let request = ResponseRequest {
model: "gpt-5.6".into(),
input: vec![
Item::Message(Message::system("Be concise.")),
Item::Message(Message::developer("Inspect supplied material literally.")),
Item::Message(Message::user(vec![
Part::text("Describe these inputs."),
Part::image_url("https://example.com/image.png"),
Part::File(File {
source: Source::Url("https://example.com/document.pdf".into()),
filename: Some("document.pdf".into()),
}),
])),
Item::FunctionCall(FunctionCall::new("call_1", "lookup", "{}")),
Item::FunctionCallOutput(FunctionCallOutput::new(
"call_1",
ToolOutput::Json(json!({"value": 1})),
)),
],
max_output_tokens: Some(256),
..Default::default()
};
Function calls and outputs are sibling Items linked by call_id; they are not nested in messages. The low-level Responses model preserves output message IDs, status, phase, annotations, log probabilities, function namespaces, and caller context. Unknown OpenAI-native Items can be represented directly with Item::Provider; the canonical history bridge intentionally does not archive them. ResponseRequest.extra exposes advanced request fields, with typed fields taking precedence.
Responses message input supports text, images, and files. The canonical Part model also represents base64 MP3/WAV audio with Part::audio_base64, so Message and Thread can retain audio input without provider JSON. OpenAI Responses create does not include audio in its input-content union, so this adapter returns Unsupported instead of emitting a non-standard wire object.
Response
Response
├─ id / model / status
├─ output: Vec<Item>
├─ usage
├─ incomplete_reason
├─ error
└─ raw
Response::text() concatenates text from assistant message Items. Response::function_calls() iterates function-call Items. The raw response remains available for provider fields not normalized by the facade.
Canonical message history
response::Item is the OpenAI Responses call model. message::Message is the provider-neutral, immutable history entity stored by Thread:
message::Message
├─ id: MessageId # runtime-minted Nyatibara identity
├─ projection # System / Developer / User / Assistant / Tool
├─ kind # content / Tool request / Tool response / opaque
├─ phase # optional provider-neutral assistant phase
├─ reasoning # normalized summary/content
├─ provenance # runtime-minted authority and typed identities
└─ provider_replay # private, exact-binding adapter residue
Standalone OpenAI reasoning Items become ordered assistant-projected Opaque Messages with normalized reasoning. Their non-derivable encrypted content and provider Item identity remain only in private replay state sealed to the exact binding. Assistant phase is a separate provider-neutral MessagePhase, not projection or authority. Applications may set it on assistant-projected input; adapters may preserve it on admitted model output. Bound projection accepts reasoning replay only when the response reports a nonblank actual model exactly equal to the requested model; provider aliases that resolve to another model name fail closed in this slice. The public low-level bridge rejects function calls and outputs because it has neither registered Tool-schema admission nor runtime settlement authority. The real OpenAiModel Agent path turns function calls into unadmitted proposals and creates canonical request/response Messages only after Agent preflight and Tool settlement. Ordinary status, annotations, logprobs, and full raw JSON are discarded, while unsupported native Items fail instead of becoming an unbounded raw archive.
# #[cfg(feature = "openai")]
use nyatibara::{
message::Message,
openai::{OpenAiBinding, input_items_for, response_messages_for},
response::ResponseRequest,
thread::{Thread, WorldStore},
};
# #[cfg(feature = "openai")]
# async fn example(response: &nyatibara::response::Response) -> Result<(), Box<dyn std::error::Error>> {
let store = WorldStore::memory();
let mut thread = Thread::new(store).await?;
let binding = OpenAiBinding::new("gpt-5.6")?;
thread.extend(response_messages_for(response, &binding)?).await?;
thread.append(Message::user("Continue.")).await?;
let request = ResponseRequest::new("gpt-5.6", input_items_for(thread.messages(), &binding)?);
# let _ = request;
# Ok(())
# }
response_messages_for requires the typed and raw top-level model identities to agree, re-decodes every raw output Item, requires full equality with its typed counterpart, and seals replay-bearing reasoning to the supplied OpenAiBinding. The binding identifies one process-local backend capability plus the exact model verified against the raw response. Reuse that same binding with input_items_for when lowering a continuation containing reasoning or Agent-admitted Tool history. The unbound response_messages imports completed assistant content but rejects function Items and reasoning that needs replay. The unbound input_items lowers replay-free content, including assistant phase, but rejects Tool Messages that need sealed provider correlation and reasoning that needs replay. All variants discard derivable raw JSON. Agent-settled canonical function outputs lower text, image, and file Parts; audio fails closed.
Message::with_reasoning attaches normalized reasoning only to application-authored Messages and assigns a fresh MessageId. It rejects runtime-admitted model/Tool provenance and Messages carrying provider replay state, preventing cloned trusted provenance or stale continuation state from surviving a payload edit.
Semantic lowering never forwards ordinary provider-native Item IDs into OpenAI. OpenAI reasoning retains its required native ID through the typed replay path. Content Messages lower from canonical semantics without provider identity. Agent-admitted Tool Messages additionally require their sealed adapter-private correlation to reconstruct the wire call_id; that correlation is distinct from semantic CallId and never becomes canonical identity. Default Debug output redacts normalized reasoning, provider replay state, media payloads, source URLs, and provider file IDs.
Typed output
Typed output forces exactly one named function call and deserializes its JSON arguments.
# #[cfg(feature = "openai")]
use nyatibara::{
openai::OpenAiResponses,
response::{ResponseRequest, TypedResponse, TypedTool},
};
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Debug, Deserialize, JsonSchema)]
struct Answer {
/// The final answer shown to the user.
answer: String,
}
# #[cfg(feature = "openai")]
# async fn example(client: &OpenAiResponses) -> Result<(), Box<dyn std::error::Error>> {
let result: TypedResponse<Answer> = client
.create_typed(
ResponseRequest::text("gpt-5.6", "Return the answer."),
TypedTool::new("emit_answer")
.description("Return the typed answer")
.strict(true),
)
.await?;
println!("{}", result.value.answer);
# Ok(())
# }
The helper derives the function parameters from T: JsonSchema, selects that function, disables parallel calls, and retains the original Response on argument decode failure.
Streaming
Responses SSE event types and coordinates are normalized while every event retains the raw JSON and sequence_number.
# #[cfg(feature = "openai")]
use nyatibara::{
openai::OpenAiResponses,
response::{Delta, ResponseRequest, StreamEventKind},
};
# #[cfg(feature = "openai")]
# async fn example(client: &OpenAiResponses) -> Result<(), Box<dyn std::error::Error>> {
let mut stream = client
.stream(ResponseRequest::text("gpt-5.6", "Count to three."))
.await?;
while let Some(event) = stream.next().await? {
if let StreamEventKind::PartDelta {
delta: Delta::Text(text),
..
} = event.kind
{
print!("{text}");
}
}
# Ok(())
# }
A stream terminates once with End, Incomplete, or Failed. A typed terminal event is only a candidate: the semantic terminal is emitted after clean transport EOF confirms that no trailing SSE event exists. A trailing event, transport/decoding failure, or idle/total timeout before EOF discards the candidate and produces Failed. Clean EOF after a response envelope was bound but without any terminal candidate produces Incomplete; EOF before any response envelope produces Failed.
Runtime boundary
ResponseClientis the static-dispatch interface for one-shot, streaming, and typed calls.OpenAiResponsesimplements/responses,/models, JSON responses, and Responses SSE.OpenAiModelaccepts any concreteResponseClient, erases it privately, and adapts it to the provider-neutralagent::Modelport.Agent::chat(&mut self, ...)implements only the minimal private model/tool loop described above.- Compaction, retries, durable storage, garbage collection, plugins, event routing, approvals, background runs, and persistent agent orchestration remain outside the implemented slice.
Thread history
Message and Checkpoint values are immutable once inserted into a shared WorldStore. A mutable Thread owns its current checkpoint and a loaded message cache. Every append, insert, update, or delete creates another checkpoint and moves only that Thread; fork produces another mutable Thread at the same checkpoint.
use nyatibara::{
message::Message,
thread::{Thread, WorldStore},
};
# async fn example() -> Result<(), nyatibara::thread::ThreadError> {
let store = WorldStore::memory();
let mut main = Thread::new(store).await?;
main.append(Message::user("question")).await?;
let mut branch = main.fork();
main.append(Message::assistant("main answer")).await?;
branch.append(Message::assistant("alternate answer")).await?;
assert_eq!(main.messages()[1].text(), "main answer");
assert_eq!(branch.messages()[1].text(), "alternate answer");
# Ok(())
# }
Thread::extend stores all Messages from one response in a single atomic checkpoint. A checkpoint may contain each immutable MessageId at most once; repeated content requires a fresh Message identity. Thread::open and Thread::checkout asynchronously rebuild the cache from any retained checkpoint. The current WorldStore is process-local; persistence and GC are intentionally deferred.