Development Guidelines

This document provides comprehensive guidelines for developers contributing to ADK-Rust. Following these standards ensures code quality, consistency, and maintainability across the project.

Table of Contents

Getting Started

Prerequisites

  • Rust: 1.95.0 or higher (edition 2024, check with rustc --version)
  • Cargo: Latest stable
  • Git: For version control
  • sccache (recommended): Compilation cache that cuts rebuild times by ~70%

Setting Up Your Environment

# Clone the repository
git clone https://github.com/zavora-ai/adk-rust.git
cd adk-rust

# Option A: Nix/devenv (reproducible — identical on Linux, macOS, CI)
devenv shell

# Option B: Setup script (installs sccache, cmake, etc.)
./scripts/setup-dev.sh

# Option C: Manual
cargo build

# Install cargo-nextest (parallel test runner, ~10x faster)
curl -LsSf https://get.nexte.st/latest/mac | tar zxf - -C ${CARGO_HOME:-~/.cargo}/bin

# Run all tests
cargo nextest run --workspace

# Check for lints
cargo clippy --all-targets --all-features

# Format code
cargo fmt --all

Environment Variables

For running examples and tests that require API keys:

# Gemini (default provider)
export GOOGLE_API_KEY="your-api-key"

# OpenAI (optional)
export OPENAI_API_KEY="your-api-key"

# Anthropic (optional)
export ANTHROPIC_API_KEY="your-api-key"

Project Structure

ADK-Rust is organized as a Cargo workspace with multiple crates:

adk-rust/
├── adk-core/       # Foundational traits and types (Agent, Tool, Llm, Event)
├── adk-telemetry/  # OpenTelemetry integration
├── adk-model/      # LLM providers (Gemini, OpenAI, Anthropic)
├── adk-tool/       # Tool system (FunctionTool, MCP, AgentTool)
├── adk-session/    # Session management (in-memory, SQLite)
├── adk-artifact/   # Binary artifact storage
├── adk-memory/     # Long-term memory with search
├── adk-agent/      # Agent implementations (LlmAgent, workflow agents)
├── adk-runner/     # Execution runtime
├── adk-server/     # REST API and A2A protocol
├── adk-cli/        # Command-line launcher
├── adk-realtime/   # Voice/audio streaming agents
├── adk-graph/      # LangGraph-style workflows
├── adk-browser/    # Browser automation tools
├── adk-eval/       # Agent evaluation framework
├── adk-rust/       # Umbrella crate (re-exports all)
└── examples/       # Working examples

Crate Dependencies

Crates must be published in dependency order:

  1. adk-core (no internal deps)
  2. adk-telemetry
  3. adk-model
  4. adk-tool
  5. adk-session
  6. adk-artifact
  7. adk-memory
  8. adk-agent
  9. adk-runner
  10. adk-server
  11. adk-cli
  12. adk-realtime
  13. adk-graph
  14. adk-browser
  15. adk-eval
  16. adk-rust (umbrella)

Code Style

General Principles

  1. Clarity over cleverness: Write code that is easy to read and understand
  2. Explicit over implicit: Prefer explicit types and error handling
  3. Small functions: Keep functions focused and under 50 lines when possible
  4. Meaningful names: Use descriptive variable and function names

Formatting

Use rustfmt with default settings:

cargo fmt --all

The CI pipeline enforces formatting. Always run cargo fmt before committing.

Naming Conventions

TypeConventionExample
Cratesadk-* (kebab-case)adk-core, adk-agent
Modulessnake_casellm_agent, function_tool
Types/TraitsPascalCaseLlmAgent, ToolContext
Functionssnake_caseexecute_tool, run_agent
ConstantsSCREAMING_SNAKE_CASEKEY_PREFIX_APP
Type parametersSingle uppercase or PascalCaseT, State

Imports

Organize imports in this order:

// 1. Standard library
use std::collections::HashMap;
use std::sync::Arc;

// 2. External crates
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;

// 3. Internal crates (adk-*)
use adk_core::{Agent, Event, Result};

// 4. Local modules
use crate::config::Config;
use super::utils;

Clippy

All code must pass clippy with no warnings:

cargo clippy --all-targets --all-features

Address clippy warnings rather than suppressing them. If suppression is necessary, document why:

#[allow(clippy::too_many_arguments)]
// Builder pattern requires many parameters; refactoring would hurt usability
fn complex_builder(...) { }

Error Handling

Structured Error Envelope

AdkError is a structured error type with component (where), category (what kind), code (machine key), message (human text), retry hint, and optional details:

use adk_core::{AdkError, ErrorComponent, ErrorCategory, Result};

// Return Result<T> (aliased to Result<T, AdkError>)
pub async fn my_function() -> Result<String> {
    let data = fetch_data().await?;

    if data.is_empty() {
        return Err(AdkError::new(
            ErrorComponent::Tool,
            ErrorCategory::NotFound,
            "tool.data.not_found",
            "No data found for the given query",
        ));
    }

    Ok(data)
}

Choosing Component and Category

ErrorComponent identifies where the failure happened (the origin subsystem, not the trait boundary it surfaces through):

ComponentUse When
AgentAgent orchestration, sub-agent dispatch
ModelLLM provider calls, response parsing
ToolTool execution, parameter validation
SessionSession persistence, state management
MemoryMemory/RAG operations
GraphGraph workflow execution
AuthAuthentication, authorization
ServerHTTP server, configuration

ErrorCategory classifies what went wrong:

CategoryHTTPUse When
InvalidInput400Bad parameters, config, request body
Unauthorized401Missing or invalid credentials
Forbidden403Valid credentials, insufficient permissions
NotFound404Resource doesn't exist
RateLimited429Upstream rate limit (retryable)
Timeout408Operation exceeded time limit (retryable)
Unavailable503Upstream service down (retryable)
Cancelled499Cancelled by caller or system
Internal500Bugs, invariant violations
Unsupported501Feature not supported

Convenience Constructors

For common patterns:

// Structured (preferred for new code)
AdkError::not_found(ErrorComponent::Session, "session.not_found", "Session xyz not found")
AdkError::rate_limited(ErrorComponent::Model, "model.openai.rate_limited", "Too many requests")
AdkError::unauthorized(ErrorComponent::Auth, "auth.token_expired", "Bearer token expired")
AdkError::timeout(ErrorComponent::Tool, "tool.execution_timeout", "Tool timed out after 30s")

// Backward-compatible (for migration — produces .legacy codes)
AdkError::tool("No data found")
AdkError::model("Provider returned 500")
AdkError::session("Session not found")

Builder API

Attach structured metadata for richer error context:

let err = AdkError::new(
    ErrorComponent::Model,
    ErrorCategory::RateLimited,
    "model.openai.rate_limited",
    "OpenAI rate limit exceeded",
)
.with_provider("openai")
.with_upstream_status(429)
.with_request_id("req-abc123")
.with_retry(RetryHint {
    should_retry: true,
    retry_after_ms: Some(5000),
    max_attempts: Some(3),
});

Retry Hints

Retryable categories (RateLimited, Unavailable, Timeout) automatically set should_retry: true. Check retryability with err.is_retryable(), which reads retry.should_retry as the single source of truth:

if err.is_retryable() {
    if let Some(delay) = err.retry.retry_after() {
        tokio::time::sleep(delay).await;
    }
    // retry the operation
}

Category Checks

err.is_retryable()    // retry.should_retry (RateLimited, Unavailable, Timeout by default)
err.is_not_found()    // category == NotFound
err.is_unauthorized() // category == Unauthorized
err.is_rate_limited() // category == RateLimited
err.is_timeout()      // category == Timeout

Component Checks (backward compat)

err.is_model()   // component == Model
err.is_tool()    // component == Tool
err.is_session() // component == Session
err.is_config()  // code == "config.legacy" (temporary bridge)

HTTP Status and Problem JSON

AdkError maps directly to HTTP responses:

let status = err.http_status_code(); // u16 based on category
let body = err.to_problem_json();    // structured JSON error body
// body: { "error": { "code", "message", "component", "category", "requestId", "retryAfter", ... } }

Crate-Local Errors with From Impls

Crates with domain-specific errors implement From<CrateLocalError> for AdkError:

// In your crate
#[derive(Debug, thiserror::Error)]
pub enum MyToolError {
    #[error("connection failed: {0}")]
    ConnectionFailed(String),
    #[error("timeout after {0}ms")]
    Timeout(u64),
}

impl From<MyToolError> for AdkError {
    fn from(err: MyToolError) -> Self {
        let (category, code) = match &err {
            MyToolError::ConnectionFailed(_) => (ErrorCategory::Unavailable, "mytool.connection"),
            MyToolError::Timeout(_) => (ErrorCategory::Timeout, "mytool.timeout"),
        };
        AdkError::new(ErrorComponent::Tool, category, code, err.to_string())
            .with_source(err)
    }
}

No Blanket From Impls

std::io::Error and serde_json::Error cross too many subsystem boundaries for a single blanket conversion. Use explicit map_err with the correct component:

// Good: explicit component and category
let data = std::fs::read_to_string(path)
    .map_err(|e| AdkError::new(
        ErrorComponent::Session,
        ErrorCategory::Internal,
        "session.io_read",
        format!("failed to read session file: {e}"),
    ).with_source(e))?;

// Good: for quick migration
let data = serde_json::from_str(&raw)
    .map_err(|e| AdkError::session(format!("JSON parse failed: {e}")))?;

Error Messages

Write clear, actionable error messages:

// Good: specific and actionable
AdkError::new(
    ErrorComponent::Model,
    ErrorCategory::InvalidInput,
    "model.missing_api_key",
    "API key not found. Set GOOGLE_API_KEY environment variable.",
)

// Bad: vague
AdkError::model("Invalid config")

No Panics in Library Code

Library crates must never panic on recoverable errors. Avoid unwrap(), expect(), and panic!() in src/ code (test code is exempt).

RwLock / Mutex: Use graceful degradation instead of unwrap(). A poisoned lock means another thread panicked — crashing the current thread too makes things worse.

// Bad: panics if lock is poisoned
let state = self.state.write().unwrap();

// Good: log and return a safe default
let Ok(state) = self.state.write() else {
    tracing::error!("state lock poisoned — returning default");
    return Default::default();
};

// Good: recover through the poison (data may be stale but won't crash)
let state = self.state.read().unwrap_or_else(|e| e.into_inner());

Constructors: Return Result when initialization can fail (e.g., connecting to external services).

// Bad: panics if Docker is not running
pub fn new(config: Config) -> Self {
    let client = connect().expect("connection failed");
    Self { client }
}

// Good: caller decides how to handle the failure
pub fn new(config: Config) -> Result<Self, MyError> {
    let client = connect().map_err(|e| MyError::Init(e.to_string()))?;
    Ok(Self { client })
}

Builder methods: Use if let Some instead of expect() for Arc::get_mut():

// Bad: panics if Arc is shared
pub fn add_callback(mut self, cb: Callback) -> Self {
    Arc::get_mut(&mut self.callbacks).expect("not shared").push(cb);
    self
}

// Good: silent no-op (builder pattern guarantees single ownership)
pub fn add_callback(mut self, cb: Callback) -> Self {
    if let Some(callbacks) = Arc::get_mut(&mut self.callbacks) {
        callbacks.push(cb);
    }
    self
}

Async Patterns

Use Tokio

All async code uses the Tokio runtime:

use tokio::sync::{Mutex, RwLock};

// Prefer RwLock for read-heavy data
let state: Arc<RwLock<State>> = Arc::new(RwLock::new(State::default()));

// Use Mutex for write-heavy or simple cases
let counter: Arc<Mutex<u32>> = Arc::new(Mutex::new(0));

Tokio Feature Convention

Library crates (adk-*) must declare only the minimal tokio features they actually use:

# Library crates — minimal features
tokio = { workspace = true, features = ["rt", "sync", "time"] }

# Binary crates only (adk-cli, examples) — full is acceptable
tokio = { workspace = true, features = ["full"] }

Never use features = ["full"] in a library crate. This forces all downstream consumers to compile every tokio subsystem whether they need it or not.

Async Traits

Use async_trait for async trait methods:

use async_trait::async_trait;

#[async_trait]
pub trait MyTrait: Send + Sync {
    async fn do_work(&self) -> Result<()>;
}

Streaming

Use EventStream for streaming responses:

use adk_core::EventStream;
use async_stream::stream;
use futures::Stream;

fn create_stream() -> EventStream {
    let s = stream! {
        yield Ok(Event::new("inv-1"));
        yield Ok(Event::new("inv-2"));
    };
    Box::pin(s)
}

Thread Safety

All public types must be Send + Sync:

// Good: Thread-safe
pub struct MyAgent {
    name: String,
    tools: Vec<Arc<dyn Tool>>,  // Arc for shared ownership
}

// Verify with compile-time checks
fn assert_send_sync<T: Send + Sync>() {}
fn _check() {
    assert_send_sync::<MyAgent>();
}

Testing

Test Runner

ADK-Rust uses cargo-nextest for test execution. Nextest runs each test binary in a separate process with parallel scheduling, giving ~10x speedup over cargo test on this workspace.

# Install (one-time)
curl -LsSf https://get.nexte.st/latest/mac | tar zxf - -C ${CARGO_HOME:-~/.cargo}/bin

# Or via devenv (included automatically)
devenv shell

Configuration lives in .config/nextest.toml with two profiles:

  • default — local development (fail-fast, no retries)
  • ci — CI runs (retries for flaky tests, slow-test warnings)

Test Organization

crate/
├── src/
│   ├── lib.rs          # Unit tests at bottom of file
│   └── module.rs       # Module-specific tests
└── tests/
    └── integration.rs  # Integration tests

Unit Tests

Place unit tests in the same file as the code:

pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_add() {
        assert_eq!(add(2, 3), 5);
    }

    #[tokio::test]
    async fn test_async_function() {
        let result = async_function().await;
        assert!(result.is_ok());
    }
}

Integration Tests

Place in tests/ directory:

// tests/integration_test.rs
use adk_core::*;

#[tokio::test]
async fn test_full_workflow() {
    // Setup
    let service = InMemorySessionService::new();

    // Execute
    let session = service.create(request).await.unwrap();

    // Assert
    assert_eq!(session.id(), "test-session");
}

Mock Testing

Use MockLlm for testing without API calls:

use adk_model::MockLlm;

#[tokio::test]
async fn test_agent_with_mock() {
    let mock = MockLlm::new(vec![
        "First response".to_string(),
        "Second response".to_string(),
    ]);

    let agent = LlmAgentBuilder::new("test")
        .model(Arc::new(mock))
        .build()
        .unwrap();

    // Test agent behavior
}

Test Commands

# Run all tests (nextest — parallel, fast)
cargo nextest run --workspace

# Run specific crate tests
cargo nextest run -p adk-core

# Run with CI profile (retries flaky tests)
cargo nextest run --workspace --profile ci

# Run doctests (nextest doesn't run these — use cargo test)
cargo test --workspace --doc

# Run ignored tests (require API keys)
cargo nextest run --workspace -- --run-ignored

# Run with output (nextest shows output for failing tests by default)
cargo nextest run --workspace --no-capture

# Devenv shortcuts
devenv shell ws-test          # nextest, default profile
devenv shell ws-test-ci       # nextest, CI profile
devenv shell ws-test-slow     # cargo test fallback (includes doctests)

Documentation

Doc Comments

Use /// for public items:

/// Creates a new LLM agent with the specified configuration.
///
/// # Arguments
///
/// * `name` - A unique identifier for this agent
/// * `model` - The LLM provider to use for reasoning
///
/// # Examples
///
/// ```rust
/// use adk_agent::LlmAgentBuilder;
///
/// let agent = LlmAgentBuilder::new("assistant")
///     .model(Arc::new(model))
///     .build()?;
/// ```
///
/// # Errors
///
/// Returns an error with component `Agent` if the model is not set.
pub fn new(name: impl Into<String>) -> Self {
    // ...
}

Module Documentation

Add module-level docs at the top of lib.rs:

//! # adk-core
//!
//! Core types and traits for ADK-Rust.
//!
//! ## Overview
//!
//! This crate provides the foundational types...

README Files

Each crate should have a README.md with:

  1. Brief description
  2. Installation instructions
  3. Quick example
  4. Link to full documentation

Documentation Tests

Ensure doc examples compile:

cargo test --doc --all

Pull Request Process

Before Submitting

  1. Run the full test suite:

    cargo nextest run --workspace
  2. Run clippy:

    cargo clippy --all-targets --all-features
  3. Format code:

    cargo fmt --all
  4. Update documentation if adding/changing public API

  5. Add tests for new functionality

PR Guidelines

  • Title: Clear, concise description of the change
  • Description: Explain what and why (not how)
  • Size: Keep PRs focused; split large changes
  • Tests: Include tests for new functionality
  • Breaking changes: Clearly document in description

Commit Messages

Follow conventional commits:

feat: add OpenAI streaming support
fix: correct tool parameter validation
docs: update quickstart guide
refactor: simplify session state management
test: add integration tests for A2A protocol

Project Scaffolding

Use cargo adk new with the Composable Template System to scaffold new projects. The built-in registry provides 12 templates, 9 add-ons, and 5 enterprise patterns:

# Basic agent (default)
cargo adk new my-agent

# Agent with tools and Docker support
cargo adk new my-agent --template tools --addon docker

# A2A protocol agent with CI and telemetry
cargo adk new my-agent --template a2a --addon ci --addon telemetry

# Graph workflow with enterprise observability
cargo adk new my-agent --template graph --addon telemetry --addon docker --addon ci

The --addon flag is composable — combine any base template with any number of addons. See the Composable Templates documentation for the full list of templates, addons, and enterprise patterns.

Common Tasks

Adding a New Tool

  1. Create the tool:
use adk_core::{Tool, ToolContext, Result};
use async_trait::async_trait;
use serde_json::Value;

pub struct MyTool {
    // fields
}

#[async_trait]
impl Tool for MyTool {
    fn name(&self) -> &str {
        "my_tool"
    }

    fn description(&self) -> &str {
        "Does something useful"
    }

    fn parameters_schema(&self) -> Option<Value> {
        Some(serde_json::json!({
            "type": "object",
            "properties": {
                "input": { "type": "string" }
            },
            "required": ["input"]
        }))
    }

    async fn execute(&self, ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
        let input = args["input"].as_str().unwrap_or_default();
        Ok(serde_json::json!({ "result": input }))
    }
}
  1. Add to agent:
let agent = LlmAgentBuilder::new("agent")
    .model(model)
    .tool(Arc::new(MyTool::new()))
    .build()?;

Adding a New Model Provider

  1. Create module in adk-model/src/:
// adk-model/src/mymodel/mod.rs
mod client;
pub use client::MyModelClient;
  1. Implement the Llm trait:
use adk_core::{Llm, LlmRequest, LlmResponse, LlmResponseStream, Result};

pub struct MyModelClient {
    api_key: String,
}

#[async_trait]
impl Llm for MyModelClient {
    fn name(&self) -> &str {
        "my-model"
    }

    async fn generate_content(
        &self,
        request: LlmRequest,
        stream: bool,
    ) -> Result<LlmResponseStream> {
        // Implementation
    }
}
  1. Add feature flag in adk-model/Cargo.toml:
[features]
mymodel = ["dep:mymodel-sdk"]
  1. Export conditionally:
#[cfg(feature = "mymodel")]
pub mod mymodel;
#[cfg(feature = "mymodel")]
pub use mymodel::MyModelClient;

Adding a New Agent Type

  1. Create module in adk-agent/src/:
// adk-agent/src/my_agent.rs
use adk_core::{Agent, EventStream, InvocationContext, Result};
use async_trait::async_trait;

pub struct MyAgent {
    name: String,
}

#[async_trait]
impl Agent for MyAgent {
    fn name(&self) -> &str {
        &self.name
    }

    fn description(&self) -> &str {
        "My custom agent"
    }

    async fn run(&self, ctx: Arc<dyn InvocationContext>) -> Result<EventStream> {
        // Implementation
    }
}
  1. Export in adk-agent/src/lib.rs:
mod my_agent;
pub use my_agent::MyAgent;

Debugging Tips

  1. Enable tracing:

    adk_telemetry::init_telemetry();
  2. Inspect events:

    while let Some(event) = stream.next().await {
        eprintln!("Event: {:?}", event);
    }
  3. Use RUST_LOG:

    RUST_LOG=debug cargo run --example myexample

Previous: ← Access Control

Questions? Open an issue on GitHub.