No description
  • Rust 90.4%
  • Python 9.4%
  • Shell 0.2%
Find a file
2026-08-16 16:52:15 +09:00
.cargo [verified] harden OpenAI Responses invariants 2026-07-28 15:03:31 +09:00
crates feat(agent): add planned and manual tool execution 2026-08-15 23:14:00 +09:00
docs fix(agent): preserve static compression prefix 2026-08-16 16:17:40 +09:00
examples feat: migrate Nyatichat to v5 agent runtime 2026-08-05 14:58:06 +09:00
scripts [verified] Implement V4 S1 semantic tool turn 2026-08-01 00:38:18 +09:00
src feat(agent): allow best-effort context compression 2026-08-16 16:52:15 +09:00
tests feat(agent): add planned and manual tool execution 2026-08-15 23:14:00 +09:00
.gitattributes [verified] Implement V4 S1 semantic tool turn 2026-08-01 00:38:18 +09:00
.gitignore [verified] freeze Nyatibara v4 design and conformance contract 2026-07-31 19:02:50 +09:00
AGENTS.md docs: define planned and manual tool execution 2026-08-15 23:13:55 +09:00
Cargo.lock feat: complete Nyatibara v5 implementation 2026-08-05 09:55:23 +09:00
Cargo.toml chore: point to canonical repository 2026-08-09 12:47:25 +09:00
deny.toml [verified] feat: establish sound agent foundation 2026-07-29 19:46:24 +09:00
LICENSE [verified] feat: establish sound agent foundation 2026-07-29 19:46:24 +09:00
README.md feat(agent): allow best-effort context compression 2026-08-16 16:52:15 +09:00

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.
  • Nyatibook is 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

  • ResponseClient is the static-dispatch interface for one-shot, streaming, and typed calls.
  • OpenAiResponses implements /responses, /models, JSON responses, and Responses SSE.
  • OpenAiModel accepts any concrete ResponseClient, erases it privately, and adapts it to the provider-neutral agent::Model port.
  • 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.

See Responses API design and lifecycle.