Building a Local-First Agent Framework in Rust (Part 13): Time, Identity, and Sessions on Disk

Share
Building a Local-First Agent Framework in Rust (Part 13): Time, Identity, and Sessions on Disk
See Part 0 for the latest table of contents and sample code. New chapters will be added over time.

Chapter 13: Time, Identity, and Sessions on Disk

So far, a session has mostly been a conversation container. It held an id and a list of messages, but the id was not doing much work. In the early chapters, that was enough. We needed a way to pass conversation history to a provider, then to the loop, then to the tool-result feedback path.

But the framework is starting to need a more durable idea of a run. Chapter 12 gave us diagnostics around the model server. The next step is to give each agent run its own identity and a place on disk. Later chapters will write events, summaries, and other artifacts there. Before we can record those things, we need to know where they belong.

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 ☕️😄

That is the framework change in this chapter: a session now has a creation timestamp, a generated id, and a storage directory under .abcb/sessions/<session-id>/.

The Rust change is just as important. To create a session id from time, the program has to read the clock. But reading the clock is not deterministic: it gives a different answer every time. So this chapter keeps those two concerns separate. One constructor receives the id and timestamp as ordinary data. The other constructor is the small place where the program asks the system clock for "now":

Session::new(id, created_at)
Session::start()

One builds a session from explicit data. The other reads the clock. Keeping that line clear makes the code easier to test and easier to reason about.

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

13.1 Session Gets Time

The Session type gains one field:

File: abcb/crates/abcb-core/src/lib.rs

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Session {
    pub id: String,
    /// When the session was created, as Unix-epoch milliseconds.
    pub created_at: u64,
    pub messages: Vec<Message>,
}

The timestamp is stored as a u64, measured in milliseconds since the Unix epoch. That is not the only possible representation. We could pull in a time crate and use a richer timestamp type. We could store an RFC 3339 string. We could use seconds instead of milliseconds.

For this project, a plain integer is enough. It serializes cleanly, compares cleanly, and keeps Session simple:

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]

That Eq still matters. We use whole-value comparisons in tests. A timestamp represented as a plain integer keeps that possible without extra ceremony.

Serialize and Deserialize matter too. In the earliest chapters, serializing a session was useful because it proved that the domain type had a stable JSON shape. We could turn a session into JSON, read it back, and check that the value survived the round trip. From this point on, a session is becoming something we may persist and reload. Deriving serde support on the whole value keeps that path open without writing custom conversion code.

Rust: why u64?

u64 is an unsigned 64-bit integer. It cannot represent negative values, and it can hold very large positive values. Unix-epoch milliseconds naturally fit that shape: they count forward from a fixed starting point. We do not need calendar operations here. We only need a stable creation marker that can be serialized, compared, and used to derive a simple id.

13.2 Pure Construction

The regular constructor now takes both the id and the timestamp:

File: abcb/crates/abcb-core/src/lib.rs

impl Session {
    pub fn new(id: impl Into<String>, created_at: u64) -> Self {
        Self {
            id: id.into(),
            created_at,
            messages: Vec::new(),
        }
    }
}

This is a pure constructor. It does not read the clock. It does not touch the filesystem. It simply takes data and builds a value. The same input gives the same Session every time.

This signature change ripples through the existing code. Anywhere that used to call:

Session::new("s")

now has to provide a timestamp:

Session::new("s", 0)

Tests that do not care about time usually pass 0. That keeps the fixture simple while still satisfying the type's new shape.

That makes tests direct:

File: abcb/crates/abcb-core/src/lib.rs

#[test]
fn session_new_records_the_given_timestamp() {
    let session = Session::new("session-1", 1_748_613_022_123);

    assert_eq!(session.id, "session-1");
    assert_eq!(session.created_at, 1_748_613_022_123);
    assert!(session.messages.is_empty());
}

The large number is written with underscores:

1_748_613_022_123

Rust ignores underscores inside numeric literals. They are only for readability. This is the same value as 1748613022123, but it is easier to scan as a timestamp-sized number.

The same pure construction also makes serialization tests stronger:

File: abcb/crates/abcb-core/src/lib.rs

#[test]
fn session_round_trips_through_json() {
    let mut session = Session::new("session-1", 1_748_613_022_123);
    session.push_message(Message::new(Role::System, "You are abcb."));
    session.push_message(Message::new(Role::User, "Create a scene."));

    let json = serde_json::to_string(&session).expect("session should serialize");
    let restored: Session = serde_json::from_str(&json).expect("session should deserialize");

    assert_eq!(restored, session);
}

Because the timestamp is supplied as data, this test does not need to race the real clock or accept a fuzzy time range. It can build the exact session it wants, serialize it, deserialize it, and compare the result directly.

13.3 The One Clock Read

Production code still needs a fresh session. That is what Session::start() is for:

File: abcb/crates/abcb-core/src/lib.rs

impl Session {
    pub fn start() -> Self {
        let now = now_millis();
        Session::new(new_session_id(now), now)
    }
}

This function is intentionally small. It reads the current time, derives an id from that time, and passes both values into the pure constructor.

The clock read is isolated here:

File: abcb/crates/abcb-core/src/lib.rs

fn now_millis() -> u64 {
    use std::time::{SystemTime, UNIX_EPOCH};
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|elapsed| elapsed.as_millis() as u64)
        .unwrap_or(0)
}

SystemTime::now() reads the system clock. That is the impure part. It depends on the machine and the moment when the function runs.

duration_since(UNIX_EPOCH) returns a Result, not a plain duration. That can look strange because most current system clocks are after January 1, 1970. But the API has to account for the case where the system clock is set before the epoch.

In this code, we collapse that unusual case with:

.unwrap_or(0)

If the clock is before the epoch, now_millis() returns 0. This is a local decision. There is not much a caller could do with "your system clock is before the Unix epoch" in this small framework, so we do not push that error into every session caller.

Rust: map on Result

map
is not only an iterator method. It is a common Rust pattern for "transform the value inside this container-like type." On an Iterator, map transforms each item. On an Option, it transforms the value inside Some and leaves None alone. On a Result, it transforms the value inside Ok and leaves Err alone.

Here, duration_since returns Result<Duration, SystemTimeError>. Calling .map(|elapsed| elapsed.as_millis() as u64) transforms only the success value. If the result is Ok(duration), we convert the duration to milliseconds. If it is Err(...), the error passes through to unwrap_or(0).

13.4 Deriving the Session Id

The id is derived from the timestamp:

File: abcb/crates/abcb-core/src/lib.rs

fn new_session_id(now_millis: u64) -> String {
    format!("sess-{now_millis}")
}

The test is direct because this helper is pure:

File: abcb/crates/abcb-core/src/lib.rs

#[test]
fn new_session_id_is_derived_from_the_timestamp() {
    assert_eq!(new_session_id(1_748_613_022_123), "sess-1748613022123");
}

This is not a perfect global identifier. Two sessions created in the same millisecond could collide. For this chapter, that is an acceptable trade. abcb is still a single-user CLI. It starts one run at a time.

If this later becomes a daemon serving multiple simultaneous runs, timestamp-derived ids may not be enough. At that point, a random id or UUID would be a better choice. But choosing that now would add a dependency and a concept before the framework needs them.

This is a recurring pattern in the project: make the current decision explicit, and know when it should be revisited.

13.5 Testing Time Without Freezing Time

Session::start() reads the real clock, so we cannot assert the exact timestamp:

let session = Session::start();
assert_eq!(session.created_at, 1_748_613_022_123); // impossible

The exact value depends on when the test runs. Instead, the test asserts the invariant that start() promises:

File: abcb/crates/abcb-core/src/lib.rs

#[test]
fn start_stamps_a_real_time_and_derives_the_id_from_it() {
    let session = Session::start();

    assert!(session.created_at > 0);
    assert_eq!(session.id, format!("sess-{}", session.created_at));
}

This is a useful testing move. When a value is nondeterministic, we should not pretend it is deterministic. We can test the relationship that should always hold instead.

Here, that relationship is simple:

session.id == format!("sess-{}", session.created_at)

The clock can give different values on every run. The id still has to be derived from the value that was actually stamped into the session.

13.6 The Caller Owns the Session

This chapter also changes the ownership of the agent loop.

Previously, run_loop created its own session internally. The caller passed a user message, and the loop built a fresh session around it. That was convenient, but it no longer fits. The CLI now needs the session id before the loop runs so it can create the session directory.

So the caller creates the session:

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

let mut session = Session::start();
session.push_message(Message::new(Role::User, message.as_str()));

Then it passes a mutable borrow into the loop:

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

run_loop(&mut provider, &registry, &mut session, DEFAULT_MAX_STEPS).await?

The new run_loop signature makes that ownership visible:

File: abcb/crates/abcb-core/src/lib.rs

pub async fn run_loop(
    provider: &mut impl Provider,
    registry: &ToolRegistry,
    session: &mut Session,
    max_steps: usize,
) -> Result<String, LoopError> {
    for _ in 0..max_steps {
        match run_step(provider, registry, session).await {
            Ok(StepOutcome::Final(answer)) => return Ok(answer),
            Ok(StepOutcome::ToolExecuted { .. }) => {}
            Err(e) => match e.recovery_feedback() {
                Some(feedback) => session.push_message(Message::new(Role::Tool, feedback)),
                None => return Err(e),
            },
        }
    }

    Err(LoopError::MaxStepsExceeded { max_steps })
}

The loop still mutates the session. It appends assistant messages, tool results, and recovery feedback. But it does not own the session anymore. It borrows it:

session: &mut Session

That gives the caller three things.

First, the caller can create storage before the loop starts because the caller already knows the session id.

Second, the session can survive an error. If run_loop owned the session and returned only Result<String, LoopError>, then on an error path the session would be dropped inside the function. When the caller owns the session, a failed run can still leave behind a session value that later code may inspect, summarize, or record.

Third, the shape now matches run_step, which already takes &mut Session. A step mutates a caller-owned session. A loop now does the same thing over many steps.

one_turn does not make the same ownership move. It still creates its own short-lived session internally:

File: abcb/crates/abcb-core/src/lib.rs

pub async fn one_turn(
    provider: &mut impl Provider,
    user_message: impl Into<String>,
) -> Result<Message, ProviderError> {
    let mut session = Session::start();
    session.push_message(Message::new(Role::User, user_message));
    provider.complete(&session).await
}

That asymmetry is intentional. one_turn is still the one-shot helper for chat and small tests. It does not create a session directory, and it does not need the caller to inspect the session afterward. run_loop is the durable run path, so its session belongs to the caller.

13.7 Giving the Session a Directory

The disk path lives in the CLI, not in abcb-core.

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

const DEFAULT_MEMORY_DIR: &str = ".abcb/sessions";

Configuration can override it through a new [memory] section:

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>,
}

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

The section is optional, and the field inside it is optional. A project can omit [memory] entirely and use .abcb/sessions. If it needs session state somewhere else, it can say:

[memory]
dir = "/tmp/sessions"

The accessor lives on Config, next to the other config accessors:

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

impl Config {
    fn memory_dir(&self) -> &Path {
        self.memory
            .as_ref()
            .and_then(|memory| memory.dir.as_deref())
            .unwrap_or_else(|| Path::new(DEFAULT_MEMORY_DIR))
    }
}

The helper returns a &Path, not a PathBuf. The relationship is similar to &str and String: PathBuf owns path data, while &Path is a borrowed view of path data. If [memory].dir exists in the config, as_deref() turns the Option<PathBuf> into an Option<&Path>, so the path is borrowed from the config instead of cloned. If it does not exist, the default string is viewed as a Path. We will look at Path and PathBuf more directly in the next section.

The actual session directory is just path composition:

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

fn session_dir(root: &Path, session_id: &str) -> PathBuf {
    root.join(session_id)
}

This function is pure. It does not create anything. It simply combines a root directory and a session id:

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

#[test]
fn session_dir_joins_root_and_id() {
    let dir = session_dir(Path::new(".abcb/sessions"), "sess-1748613022123");
    assert_eq!(dir, PathBuf::from(".abcb/sessions/sess-1748613022123"));
}

Only the next helper touches disk:

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

fn create_session_dir(root: &Path, session_id: &str) -> io::Result<PathBuf> {
    let dir = session_dir(root, session_id);
    fs::create_dir_all(&dir)?;
    Ok(dir)
}

This is the same boundary habit again. Build paths as data first. Touch the filesystem only at the edge.

13.8 Path, PathBuf, and create_dir_all

Path and PathBuf have the same relationship as str and String in many ordinary cases.

Path is a borrowed path-like view. PathBuf is an owned, growable path buffer. When a function only needs to read or join from a path, it can usually accept &Path. When a function needs to return a newly built path, it returns PathBuf.

That is why the two helpers have these signatures:

fn session_dir(root: &Path, session_id: &str) -> PathBuf
fn create_session_dir(root: &Path, session_id: &str) -> io::Result<PathBuf>

Both borrow the root path. Both return an owned path because root.join(session_id) creates a new path value.

The filesystem operation uses:

fs::create_dir_all(&dir)?;

create_dir_all creates the target directory and any missing parent directories. It is also idempotent for an existing directory, which means calling it again for the same path is not an error.

The tests document both facts:

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

#[test]
fn create_session_dir_makes_the_directory_under_the_root() {
    let root = tempfile::tempdir().expect("temp dir");
    let dir = create_session_dir(root.path(), "sess-42").expect("should create");

    assert_eq!(dir, root.path().join("sess-42"));
    assert!(dir.is_dir());
}

#[test]
fn create_session_dir_is_idempotent() {
    let root = tempfile::tempdir().expect("temp dir");
    create_session_dir(root.path(), "sess-42").expect("first create");
    create_session_dir(root.path(), "sess-42").expect("second create is a no-op");
}

Using tempfile::tempdir() keeps these tests away from the real project directory. The test creates a temporary root, verifies behavior inside that root, and lets the temporary directory clean itself up.

13.9 Wiring It Into run

The run command now creates a session before choosing the provider path:

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

async fn run_run(message: String, mock: bool) -> Result<(), Box<dyn Error>> {
    let registry = default_registry(PathBuf::from(NOTES_PATH));

    let mut session = Session::start();
    session.push_message(Message::new(Role::User, message.as_str()));

    let answer = if mock {
        create_session_dir(Path::new(DEFAULT_MEMORY_DIR), &session.id)?;
        let mut provider = MockProvider::new([format!(
            r#"{{"kind":"final","content":"mock run: you said {message}"}}"#
        )]);
        run_loop(&mut provider, &registry, &mut session, DEFAULT_MAX_STEPS).await?
    } else {
        let config = load_required_config()?;
        create_session_dir(config.memory_dir(), &session.id)?;
        let mut provider = build_provider(&config)?;
        run_loop(&mut provider, &registry, &mut session, config.max_steps()).await?
    };
    println!("{answer}");

    Ok(())
}

The session is created once:

let mut session = Session::start();

The user message is pushed once:

session.push_message(Message::new(Role::User, message.as_str()));

Then both provider branches use the same session. The mock path creates a directory under the default memory root. The real-provider path loads config and uses config.memory_dir().

This matters because session identity should not depend on whether the provider is mock or real. The provider answers the conversation. It should not decide where the run lives.

There is also a small ownership detail in this line:

Message::new(Role::User, message.as_str())

message is a String owned by run_run. The session needs to own its message content. Message::new accepts impl Into<String>, so passing message by value would work, but then message would be moved. We still need message later when formatting the mock response:

format!(r#"{{"kind":"final","content":"mock run: you said {message}"}}"#)

So we pass message.as_str() instead. Message::new copies that borrowed string slice into its own owned String, and run_run keeps the original message available for the mock provider setup.

13.10 What Changed

Chapter 13 gives a run its first durable shape. A Session now has an id, a creation timestamp, and a directory under .abcb/sessions/<session-id>/.

The Rust lesson is the split between pure and impure construction. Session::new(id, created_at) is deterministic and easy to test. Session::start() is the small impure wrapper that reads the clock, derives an id, and delegates to new.

The ownership lesson is that run_loop no longer owns the session. The caller creates it, seeds it with the user message, creates storage from its id, and then lends it to the loop as &mut Session. That will matter more in the next chapter, when the loop starts recording events into the session's directory.

To be continued

Read more