Building a Local-First Agent Framework in Rust (Part 10): Configuration With TOML

Share
Building a Local-First Agent Framework in Rust (Part 10): Configuration With TOML
See Part 0 for the latest table of contents and sample code. New chapters will be added over time.

Chapter 10: Configuration With TOML

So far, abcb has been mostly self-contained. The mock provider lives in the process. The default notes file is hardcoded. The loop step limit is a constant. That was a useful shape while we were building the core loop, because it kept the early chapters focused on messages, tools, envelopes, and recovery. But a local-first agent framework cannot stay that way for long.

This post is also available on Medium. If you’re a paid Medium member and happen to read it there, it helps fund my next cup of coffee. Much appreciated ☕️😄

Once the framework talks to a real local model, the binary needs to know where that model server is running. My machine may use one base URL. Another machine may use another port. The model identifier may be a local path, a short name, or whatever the OpenAI-compatible server expects. Session files may live under .abcb/sessions for one project and somewhere else for another.

Those choices are not source code. They are local runtime configuration, and this chapter adds the first real abcb.toml shape for them. It is still modest, and not every parsed value is wired into runtime behavior yet. That is intentional. We are preparing the edge of the program before Chapter 11 brings in the real HTTP provider.

The sample code for this chapter is in chapter10/abcb/.

10.1 Why Configuration Belongs at the Edge

The new configuration types live in the CLI crate:

File: abcb/crates/abcb-cli/src/main.rs

#[derive(Debug, Deserialize, PartialEq, Eq)]
struct Config {
    project: Option<ProjectConfig>,
    model: Option<ModelConfig>,
    agent: Option<AgentConfig>,
    memory: Option<MemoryConfig>,
}

This is a small placement decision, but it matters. The core crate should not need to know what an abcb.toml file looks like. By "the file format," I mean the TOML section names, which sections are optional, which fields are required, how defaults are interpreted, and where the file is loaded from. The provider will eventually need plain values such as a base URL and a model name. The loop needs a max_steps number. The tool registry may need a memory directory. None of those lower layers need to depend on the whole TOML shape.

So the configuration stays at the edge. The CLI reads TOML, turns it into Rust values, and later it can pass plain arguments inward. That keeps the inner crates parameterized by the values they need, not by the project file that happened to provide those values. It also keeps the format easier to change. If the TOML layout changes later, the blast radius should stay near the CLI.

10.2 Adding TOML Parsing

The workspace adds toml as a shared dependency:

File: abcb/Cargo.toml

[workspace.dependencies]
clap = { version = "4.6.1", features = ["derive"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
tempfile = "3.23.0"
toml = "1.1.2"

The CLI crate then uses it through the workspace dependency:

File: abcb/crates/abcb-cli/Cargo.toml

[dependencies]
abcb-core = { path = "../abcb-core" }
abcb-tools = { path = "../abcb-tools" }
clap = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }

The parsing function itself stays almost too small to notice:

File: abcb/crates/abcb-cli/src/main.rs

fn parse_config(source: &str) -> Result<Config, toml::de::Error> {
    toml::from_str(source)
}

This works because the config structs derive Deserialize. The toml crate reads the source text, Serde maps TOML tables onto Rust structs, and the result is either a Config or a toml::de::Error.

TOML

TOML is a configuration file format designed to be readable as plain text. A table such as [model] groups related keys, and a line such as base_url = "http://localhost:8083/v1" assigns a value inside that table. Rust does not parse TOML by itself, so we use the toml crate together with Serde.
TOML, YAML, and JSON

JSON is strict and universal, but it is not pleasant for hand-written config because it requires quotes everywhere and does not allow comments. YAML is flexible and common, but that flexibility can make small files surprisingly ambiguous. TOML sits in the middle: it is readable, has clear sections, supports comments, and maps cleanly onto structs. For a local project config like abcb.toml, that balance is a good fit.

The caller does not parse the file directly. It goes through load_config:

File: abcb/crates/abcb-cli/src/main.rs

fn load_config(path: &Path) -> Result<Option<Config>, Box<dyn Error>> {
    if !path.exists() {
        return Ok(None);
    }

    let source = fs::read_to_string(path)?;
    let config = parse_config(&source)?;

    Ok(Some(config))
}

There are two different cases to handle here. If the file does not exist, that is not an error yet:

return Ok(None);

The CLI can still run without abcb.toml, especially while the mock provider remains the only supported provider. If the file exists, reading it or parsing it can fail:

let source = fs::read_to_string(path)?;
let config = parse_config(&source)?;

Those failures return as errors. A missing config is okay. A present but unreadable or malformed config is loud. That is a useful distinction for local tooling.

10.3 Optional Sections, Required Fields

The interesting part of this chapter is not the parser call, but the type shape.

File: abcb/crates/abcb-cli/src/main.rs

#[derive(Debug, Deserialize, PartialEq, Eq)]
struct Config {
    project: Option<ProjectConfig>,
    model: Option<ModelConfig>,
    agent: Option<AgentConfig>,
    memory: Option<MemoryConfig>,
}

Every top-level section is optional. An empty config file is valid:

File: abcb/crates/abcb-cli/src/main.rs

#[test]
fn parses_empty_config() {
    let config = parse_config("").expect("empty config should parse");

    assert_eq!(config.project_name(), None);
}

That is what Option<ProjectConfig>, Option<ModelConfig>, Option<AgentConfig>, and Option<MemoryConfig> mean. The config may have a [model] section, or it may not. But if a section is present, its fields can still have their own rules. The model section looks like this:

File: abcb/crates/abcb-cli/src/main.rs

/// MLX provider endpoint settings. Present only when `[model]` is configured.
#[derive(Debug, Deserialize, PartialEq, Eq)]
struct ModelConfig {
    /// OpenAI-compatible base URL, e.g. `http://localhost:8083/v1`.
    base_url: String,
    /// Model identifier sent in the request body (the local model path).
    model: String,
    /// Optional health endpoint used by `abcb doctor`.
    health_url: Option<String>,
}

base_url and model are plain String fields, not Option<String>. That means a present [model] section must provide them. health_url is different:

health_url: Option<String>,

The model section can have a health URL, but it does not have to. This gives us a useful rule without writing custom validation code: the section is optional, but required fields inside a present section are required by type.

The happy path test includes all three fields:

File: abcb/crates/abcb-cli/src/main.rs

#[test]
fn parses_model_config() {
    let config = parse_config(
        r#"
        [model]
        base_url = "http://localhost:8083/v1"
        health_url = "http://localhost:8083/health"
        model = "/path/to/model"
        "#,
    )
    .expect("model config should parse");

    let model = config.model.expect("model section present");
    assert_eq!(model.base_url, "http://localhost:8083/v1");
    assert_eq!(model.model, "/path/to/model");
    assert_eq!(
        model.health_url.as_deref(),
        Some("http://localhost:8083/health")
    );
}

Notice the last assertion:

model.health_url.as_deref()

health_url is an Option<String>, but the expected value is written as Some("http://localhost:8083/health"), which is an Option<&str>. as_deref() converts Option<String> into Option<&str> by borrowing the inner string. That lets the assertion compare borrowed text without cloning the string.

The next test proves health_url is optional:

File: abcb/crates/abcb-cli/src/main.rs

#[test]
fn parses_model_config_without_health_url() {
    let config = parse_config(
        r#"
        [model]
        base_url = "http://localhost:8083/v1"
        model = "/path/to/model"
        "#,
    )
    .expect("model config without health_url should parse");

    let model = config.model.expect("model section present");
    assert_eq!(model.health_url, None);
}

And the required-field test proves that model itself is not optional once [model] exists:

File: abcb/crates/abcb-cli/src/main.rs

#[test]
fn model_config_requires_base_url_and_model() {
    let err = parse_config(
        r#"
        [model]
        base_url = "http://localhost:8083/v1"
        "#,
    )
    .expect_err("missing model field should error");

    assert!(err.to_string().contains("model"));
}

The TOML is syntactically valid. The error happens because Serde cannot build ModelConfig without the required model: String field.

10.3.1 as_ref(), as_deref(), and Deref Coercion

The config code uses the same small pattern in a few places:

File: abcb/crates/abcb-cli/src/main.rs

fn project_name(&self) -> Option<&str> {
    self.project.as_ref()?.name.as_deref()
}

This function starts with a borrowed Config (&self) and returns a borrowed string slice. It does not clone the project name. It only borrows through the nested Option values.

The general idea behind as_ref() is simple: keep the outer container, but borrow the value inside it.

let owned: Option<String> = Some(String::from("abcb"));
let borrowed: Option<&String> = owned.as_ref();

The outer shape is still Option. Some stays Some, and None stays None. The difference is inside the Option: String becomes &String. This is useful when we want to inspect a value without taking ownership of it.

The first step is as_ref():

self.project.as_ref()

self.project is an Option<ProjectConfig>, but the method only has &self. It cannot move the ProjectConfig out of the borrowed Config. as_ref() turns Option<ProjectConfig> into Option<&ProjectConfig>, so the code can inspect the section by reference.

The ? then handles the missing-section case:

self.project.as_ref()?

If there is no [project] section, the function returns None. The ? operator does not have to appear at the end of a statement. It works wherever the expression appears. Here, if self.project.as_ref() is None, ? returns None from project_name() immediately. If it is Some(project), ? unwraps it and the rest of the chain continues with &ProjectConfig.

The final step is as_deref():

name.as_deref()

name is an Option<String>. The function wants to return Option<&str>. as_deref() is a little more specialized than as_ref(): it first borrows the inner value, then converts that borrowed value through the type's Deref implementation.

let opt: Option<String> = Some(String::from("hi"));
let opt_ref: Option<&str> = opt.as_deref();

For String, the Deref target is str. That does not mean we usually hold a bare str value, because str is an unsized text slice. In normal code we use it behind a reference, as &str. So Option<String> can become Option<&String> by borrowing, and then Option<&str> by dereferencing the borrowed String to a borrowed str view.

You can think of as_deref() as the convenient version of:

let opt: Option<String> = Some(String::from("hi"));
let opt_ref: Option<&str> = opt.as_ref().map(|s| s.as_str());

Both versions preserve the Option shape and borrow the inner string. The s.as_str() call is the direct string method version. as_deref() is more general: it works for types that implement Deref, not only for String.

This is related to deref coercion. At function-call boundaries, Rust can automatically apply the Deref trait to make borrowed types line up:

fn print_str(s: &str) {
    println!("{s}");
}

let owned = String::from("hi");
print_str(&owned);

print_str wants &str, but &owned is &String. This works because String implements Deref<Target = str>, and the compiler can coerce &String to &str for the function call.

The Deref trait

Deref is the trait for types that can behave like a reference to another type. Classic smart pointers such as Box<T>, Rc<T>, and Arc<T> implement it, but owned containers such as String, Vec<T>, and PathBuf implement it too. In simplified form, the trait says: "when someone dereferences me, I can provide a reference to my target."
Common examples:

as_deref() uses the same underlying idea, but inside an Option. It says: borrow the value inside this Option, then dereference that borrowed value so the result is a reference to the target type. That is why project_name() can return Option<&str> even though the config stores the project name as an owned String.

10.4 Defaults Belong in Accessor Methods

The [agent] section controls loop tuning:

File: abcb/crates/abcb-cli/src/main.rs

/// Agent-loop tuning. Present only when `[agent]` is configured.
#[derive(Debug, Deserialize, PartialEq, Eq)]
struct AgentConfig {
    /// Ceiling on agent-loop iterations. Falls back to `DEFAULT_MAX_STEPS`.
    max_steps: Option<usize>,
}

The field is optional. A config file can omit [agent] completely, and a present [agent] section can omit max_steps. The default is not written into the struct while TOML is being parsed. Instead, the struct keeps the missing value as None.

The default appears only when someone explicitly calls an accessor method:

File: abcb/crates/abcb-cli/src/main.rs

impl Config {
    /// Configured agent step ceiling, or `DEFAULT_MAX_STEPS` if unset.
    #[allow(dead_code)]
    fn max_steps(&self) -> usize {
        self.agent
            .as_ref()
            .and_then(|agent| agent.max_steps)
            .unwrap_or(DEFAULT_MAX_STEPS)
    }
}

max_steps() is not special to Rust or Serde. It is just a method we wrote. In this chapter, the tests call it directly. Later, the runtime path can call the same method when it needs the effective step limit.

Read the method chain from left to right:

self.agent
    .as_ref()
    .and_then(|agent| agent.max_steps)
    .unwrap_or(DEFAULT_MAX_STEPS)

self.agent is an Option<AgentConfig>. as_ref() turns that into Option<&AgentConfig>, so the method can inspect the agent section without moving it out of self. and_then(|agent| agent.max_steps) looks inside the section. If there is no agent section, the result stays None. If there is an agent section, the closure returns its max_steps, which is already an Option<usize>. Finally, unwrap_or(DEFAULT_MAX_STEPS) returns the configured number or the default.

Why not #[serde(default)]?

Serde can fill in defaults during deserialization, and that is useful in many programs. Here the accessor method keeps the parsed file closer to what the user actually wrote. agent.max_steps remains None when it was absent, and Config::max_steps() decides what value the runtime should use. That separation is small, but useful: the struct represents the file, and the method represents runtime interpretation.

The tests show both paths:

File: abcb/crates/abcb-cli/src/main.rs

#[test]
fn parses_agent_max_steps() {
    let config = parse_config(
        r#"
        [agent]
        max_steps = 9
        "#,
    )
    .expect("agent config should parse");

    assert_eq!(config.max_steps(), 9);
}

File: abcb/crates/abcb-cli/src/main.rs

#[test]
fn max_steps_defaults_to_five_when_absent() {
    let config = parse_config("").expect("empty config should parse");

    assert_eq!(config.max_steps(), DEFAULT_MAX_STEPS);
}

At this point, max_steps() is tested but not wired into run_run yet. The run path still uses the constant directly:

File: abcb/crates/abcb-cli/src/main.rs

const DEFAULT_MAX_STEPS: usize = 5;

File: abcb/crates/abcb-cli/src/main.rs

let answer = run_loop(&mut provider, &registry, &message, DEFAULT_MAX_STEPS)?;

That may look incomplete, and in a sense it is. But it is a controlled kind of incomplete. The config shape is being prepared now because the next chapter will need it when the real provider path appears.

10.5 PathBuf and &Path

The memory section introduces path types:

File: abcb/crates/abcb-cli/src/main.rs

/// Session-memory settings. Present only when `[memory]` is configured.
#[derive(Debug, Deserialize, PartialEq, Eq)]
struct MemoryConfig {
    /// Directory holding per-session state. Falls back to `.abcb/sessions`.
    dir: Option<PathBuf>,
}

PathBuf is an owned filesystem path. It is similar in spirit to String, but for paths instead of text. The accessor returns &Path:

File: abcb/crates/abcb-cli/src/main.rs

impl Config {
    /// Configured session directory, or `.abcb/sessions` if unset.
    #[allow(dead_code)]
    fn memory_dir(&self) -> &Path {
        self.memory
            .as_ref()
            .and_then(|memory| memory.dir.as_deref())
            .unwrap_or_else(|| Path::new(".abcb/sessions"))
    }
}

This looks similar to max_steps(), but the return type changes the details. memory.dir is an Option<PathBuf>. The method does not want to return an owned PathBuf, because returning the configured path by value would require cloning it. It only needs to return a borrowed path for callers to read.

This is the same as_deref() pattern from 10.3.1, now with paths instead of strings:

.and_then(|memory| memory.dir.as_deref())

It turns Option<PathBuf> into Option<&Path>. If the user configured a directory, the accessor returns a borrowed view of that stored path. If the user did not configure a directory, the method returns a borrowed path made from a string literal:

.unwrap_or_else(|| Path::new(".abcb/sessions"))
Path and PathBuf

PathBuf
owns a filesystem path. Path is a borrowed view of a path. The relationship is similar to String and str: owned value on one side, borrowed view on the other. When a struct stores a path, PathBuf is often the right type. When a function only needs to read a path, &Path is often enough.

The tests again show configured and default behavior:

File: abcb/crates/abcb-cli/src/main.rs

#[test]
fn parses_memory_dir() {
    let config = parse_config(
        r#"
        [memory]
        dir = "/tmp/sessions"
        "#,
    )
    .expect("memory config should parse");

    assert_eq!(config.memory_dir(), Path::new("/tmp/sessions"));
}

File: abcb/crates/abcb-cli/src/main.rs

#[test]
fn memory_dir_defaults_when_absent() {
    let config = parse_config("").expect("empty config should parse");

    assert_eq!(config.memory_dir(), Path::new(".abcb/sessions"));
}

10.6 #[allow(dead_code)]

Two accessor methods have an attribute above them:

File: abcb/crates/abcb-cli/src/main.rs

#[allow(dead_code)]
fn max_steps(&self) -> usize {
    self.agent
        .as_ref()
        .and_then(|agent| agent.max_steps)
        .unwrap_or(DEFAULT_MAX_STEPS)
}

File: abcb/crates/abcb-cli/src/main.rs

#[allow(dead_code)]
fn memory_dir(&self) -> &Path {
    self.memory
        .as_ref()
        .and_then(|memory| memory.dir.as_deref())
        .unwrap_or_else(|| Path::new(".abcb/sessions"))
}

dead_code means the compiler sees code that is not used by the normal binary path. These methods are used in tests, but not yet in the runtime path. The attribute is a promise to the reader. It says: this code is intentionally ahead of the wiring.

That promise should be temporary. Once max_steps() or memory_dir() is used by the runtime path, the attribute should disappear. Otherwise, #[allow(dead_code)] becomes noise. It should mark a known transition point, not become a habit.

10.7 Local Configuration Should Stay Local

The .gitignore file already protects local runtime state:

File: abcb/.gitignore

# abcb local state
/.abcb/
/abcb.toml

It also ignores environment files and local process artifacts:

File: abcb/.gitignore

# Local runtime artifacts
.env
.env.*
!.env.example
*.pid

This chapter does not add a committed abcb.toml file. That is deliberate. A real local model configuration can contain machine-specific paths and endpoints. It may point to a model server running on a private port, or to a local model path that only exists on one computer.

The source code should define how configuration is shaped and parsed. The actual local configuration should be created by the developer on their machine. When we need a shared example later, an abcb.example.toml file may be useful. But the live abcb.toml should stay local.

10.8 doctor Still Reads the Config

The doctor command already checks whether abcb.toml exists:

File: abcb/crates/abcb-cli/src/main.rs

fn run_doctor() -> Result<(), Box<dyn Error>> {
    println!("abcb doctor");
    println!("workspace: ok");

    match load_config(Path::new("abcb.toml"))? {
        Some(config) => {
            println!("config: found abcb.toml");
            if let Some(name) = config.project_name() {
                println!("project: {name}");
            }
        }
        None => println!("config: abcb.toml not found (ok for now)"),
    }

    Ok(())
}

Only the project name is printed for now:

if let Some(name) = config.project_name() {
    println!("project: {name}");
}

That means Chapter 10 is not trying to turn doctor into a full configuration report. It only extends the parser and the data model. More of the parsed values will become visible when the real provider and health check arrive. The important part is that doctor already has the right boundary:

match load_config(Path::new("abcb.toml"))?

The CLI owns the config file path and decides what to do when the file is missing. The inner crates do not participate in that decision.

10.9 What We Chose

This chapter adds configuration without moving configuration knowledge into the core framework.

Config lives in abcb-cli, where the TOML file is read. Top-level sections are optional, but required fields inside a present section are enforced by Rust types and Serde. [model] can be absent, but if it is present, base_url and model are required. health_url remains optional.

Defaults are handled by accessor methods such as Config::max_steps() and Config::memory_dir(). That keeps the parsed struct close to the file the user wrote, while giving the runtime a clean way to ask for interpreted values.

PathBuf appears because paths are not just strings. The config owns a path with PathBuf, and the accessor returns a borrowed &Path, using the same owned-value-to-borrowed-view pattern we saw with String and str.

Some of the config is intentionally ahead of runtime wiring. The #[allow(dead_code)] attributes mark that transition. They should disappear when the values are consumed by the real provider and session storage paths.

The result is not dramatic, but it is important. The same binary can now have a local configuration shape, and the framework is ready to talk to different local model setups without hardcoding those choices into the inner crates.

To be continued

Read more