localharness
A Rust-native agent SDK for Gemini. Build production agents with
streaming text, custom tools, safety policies, and background triggers
— all from a single cargo add. Zero external binaries.
use ;
async
Status: 0.2.x · stable Rust-native runtime · 10/11 built-in tools shipping.
Contents
- Install
- Concepts —
Agent,Conversation,Connection - Examples — streaming, tools, hooks, policies, triggers, multimodal
- Built-in tools
- Architecture
- Design notes
- FAQ
- License
Install
[]
= "0.2"
= { = "1", = ["macros", "rt-multi-thread"] }
No Python install, no Go binary, no harness process — cargo build and
you have an agent. Get an API key from Google AI Studio.
Concepts
| Layer | Type | Use when |
|---|---|---|
| 1 | Agent |
One-shot or short-running scripts. Batteries included. |
| 2 | Conversation / ChatResponse |
Long-lived sessions, history introspection, custom turn shapes. |
| 3 | Connection |
Embed the SDK in your own runtime, swap the transport. |
Examples
use StreamExt;
let response = agent.chat.await?;
let mut tokens = response.text_stream;
while let Some = tokens.next.await
Every cursor (text_stream, thoughts, tool_calls) replays from chunk
zero and advances independently — safe to consume concurrently from
multiple tasks.
use StreamExt;
let response = agent.chat.await?;
let thoughts = async ;
let calls = async ;
let = join!;
a?; b?;
use ;
use json;
let weather = new;
let agent = start_gemini.await?;
use ;
let agent = start_gemini.await?;
let response = agent.chat.await?;
println!;
workspace_only(...) policies are auto-installed when with_workspace
is set; every file tool's path is canonicalized and rejected if it
escapes the workspace.
use ;
use Arc;
let policies = vec!;
Precedence: specific deny ≻ specific ask ≻ specific allow ≻ wildcard deny ≻ wildcard ask ≻ wildcard allow. Matches the Python SDK rule.
let schema = json!;
let agent = start_gemini.await?;
let response = agent.chat.await?;
let _ = response.text.await?; // drain
let out = agent.conversation.last_structured_output.unwrap;
println!;
The model calls the built-in finish(output) tool when it's done; the
agent extracts output into last_structured_output().
use Duration;
use every;
let watchdog = every;
let agent = start_gemini.await?;
use ;
let chart = from_path?
.with_description;
let spec = from_path?;
let prompt: Content = vec!.into;
let response = agent.chat.await?;
Media is stored as Bytes — cloning into multiple stream frames is
refcounted, so a 30 MB PDF is never copied.
let agent = start_gemini.await?;
Built-in tools
The Gemini backend ships 10 of 11 tools enabled by BuiltinTool,
auto-registered into the ToolRunner per CapabilitiesConfig. The
default CapabilitiesConfig exposes the read-only safety subset; call
CapabilitiesConfig::unrestricted() to enable everything.
| Tool | Read/Write | Description |
|---|---|---|
list_directory |
R | Sorted children with name, kind, size. |
view_file |
R | UTF-8 lossy read with optional 1-indexed line range; 256 KiB cap. |
find_file |
R | Glob-matched recursive name search; 1000-match cap. |
search_directory |
R | Regex content search with optional file glob; 500-match cap. |
finish |
term | Terminate turn + capture structured output. |
create_file |
W | Atomic write via tempfile + rename; refuses to overwrite. |
edit_file |
W | Exact-once substring replace (or replace_all); atomic write. |
run_command |
W | Shell exec with timeout (default 30s / max 600s), 256 KiB output cap. |
generate_image |
W | Call the image model; returns base64 + MIME. |
ask_question |
I/O | Default no-op (returns skipped: true); register a custom ask_question tool for interactive UI. |
start_subagent |
— | Not yet implemented (lands in 0.3.x). |
Custom tools registered with the same name as a built-in win — overrides are intentional.
Architecture
┌──────────────────────────────────────────────────────┐
│ L1 Agent start · chat · shutdown │
├──────────────────────────────────────────────────────┤
│ L2 Conversation history · usage · streams │
│ ChatResponse text · thoughts · tool_calls │
├──────────────────────────────────────────────────────┤
│ L3 Connection transport abstraction │
│ GeminiConnection reqwest + SSE + tool loop │
└──────────────────────────────────────────────────────┘
│
│ HTTPS (rustls)
▼
Gemini API
Inside the Gemini agent loop:
user prompt ───►│ ▲
│ build GenerateContentRequest │ emit Step
│ ───────► Gemini SSE ──────────► chunks │ (text,
│ │ │ thought,
│ ▼ │ tool_call)
│ functionCall parts? ────► dispatch ──┘
│ │ hooks→policy→tool_runner
│ ▼
│ append functionResponse ──► loop ─────┐
│ │
│ no more calls / finish ──► terminal Step
A single broadcast channel fans Steps out to every cursor
(ChatResponse::chunks, text_stream, thoughts, tool_calls). The
tool dispatch loop is inline inside the turn — no out-of-band
round-trip through a sidecar process.
Design notes (performance & safety)
- Lock-free idle polling.
Connection::is_idle()reads anAtomicBool. Trigger handlers can hot-loop without contention. - Broadcast fan-out for steps. Cursors subscribe without blocking the producer; replay buffer is bounded; slow consumers fail fast.
- Bounded backpressure everywhere. Step broadcast cap 256.
Function-call dispatch capped at 16 rounds per turn (
MAX_TOOL_ROUNDS). - Atomic file writes.
create_fileandedit_filewrite through atempfile::NamedTempFilein the same directory and rename into place — a crash mid-write never leaves a partially written file. - Bounded subprocess output.
run_commandcaps each stream at 256 KiB and kills the child on timeout withkill_on_drop. - Component-wise path containment.
workspace_only()defeats prefix tricks (/foo/bar-evilvs/foo/bar). - Lock-free tool-context swap.
arc_swap::ArcSwapOptionreplaces the runtime context atomically across concurrent tool calls. - Typed errors. Flat
thiserrorenum;io::Error,serde_json::Error,reqwest::Errorfold via#[from]. - API key redaction.
DebugforGeminiClientprints<redacted>for the key. - Zero-copy media.
Media::dataisbytes::Bytes. Cloning a part into multiple frames is a refcount bump.
FAQ
Does this need a server? No. The crate uses reqwest to call the
Gemini REST API directly. No localhost daemon, no Go binary, no Python.
How do I get a GEMINI_API_KEY? From Google AI Studio.
Free tier is sufficient for development.
Which model does it use? Default gemini-3.5-flash for chat,
gemini-3.1-flash-image-preview for generate_image. Override with
GeminiBackendConfig::with_model(...).
Why does write-tool access require a policy? Enabling tools that
write to disk or run commands without a policy is almost always a bug.
Add with_policies(vec![allow_all()]) to opt in, or
with_workspace(...) to scope.
MSRV? Rust 1.85 (edition 2024).
Async runtime? Tokio.
How do I get tracing logs?
fmt.with_env_filter.init;
What about the 0.1.x start_local / Go binary? Still works in
0.2.x but marked #[deprecated]; removed in 0.3.0. Migrate to
start_gemini. See UPSTREAM.md and
DESIGN.md for the historical context.