Building a Local-First Agent Framework in Rust (Part 5): Recording What Happened With an Event Log

Share
Building a Local-First Agent Framework in Rust (Part 5): Recording What Happened With an Event Log
See Part 0 for the latest table of contents and sample code. New chapters will be added over time.

Chapter 5: Recording What Happened With an Event Log

At the end of the previous chapter, abcb could run one mocked model turn. A user message went into a temporary session, a provider produced an assistant message, and the CLI could print the reply with chat --mock.

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 a small step, but it already creates a problem.

If an agent makes a strange decision, we need to know what happened. What did the user ask? What did the model return? What did the framework decide was the final answer? Later, once tools enter the loop, this becomes even more important. A tool-using agent can fail in several different places: the model can ask for the wrong thing, the parser can misunderstand the model, a tool can fail, or the framework can make the wrong recovery choice.

If all we have is terminal output, we only see the last surface. We need a record.

So this chapter adds an event log. It is deliberately simple: an append-only JSONL file. Each line is one event. The CLI can opt into writing it with --log, and a new replay command can read it back.

This is observability before features. We are not making the agent smarter yet. We are making it easier to see what the agent did.

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

5.1 Events Are Not Messages

Chapter 3 gave us Message. A message is part of a conversation. It has a role and content:

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

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Message {
    pub role: Role,
    pub content: String,
}

That is not the same thing as an event.

A message says, "this text belongs to the conversation." An event says, "this thing happened in the framework." Those overlap sometimes, but they are not the same layer. A user message can become an event. A model response can become an event. Later, tool calls, parse failures, approvals, and recovery attempts can also become events. Not all of those belong directly in the conversation transcript.

This chapter adds the first event type:

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

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Event {
    UserMessage { content: String },
    ModelResponse { content: String },
    FinalAnswer { content: String },
}

There are only three variants for now:

  • UserMessage: what the user sent into this run
  • ModelResponse: what the provider returned
  • FinalAnswer: what the CLI treated as the final answer

In this chapter, ModelResponse and FinalAnswer often contain the same text. That can look redundant, but it is intentional. ModelResponse records what came back from the provider. FinalAnswer records what the framework decided to show as the answer for this run.

Those two things are the same only because the current flow is still simple. Later, the model may return a structured decision that asks for a tool call. The framework may run the tool, send the result back to the model, and wait for another response before it has a final answer. If the log already separates "what the model returned" from "what the framework finally answered," we can add those middle steps without changing the meaning of the existing events.

5.2 Three JSON Layers

It is worth naming a distinction that will keep coming back.

abcb will eventually have at least three JSON-shaped layers:

  • Message: conversation data, such as role and content
  • Envelope: the model's structured decision, which will appear later
  • Event: the audit trail of what happened in the framework

They are all serializable. They may all contain text. But their jobs are different.

A Message is what the provider sees as conversation context. An envelope will be what the model returns when it chooses between answering, calling a tool, or doing something else. An Event is what we record so a human can inspect the run afterward.

There can be duplication between them, but the ownership is different. If the user types "hello", that text may appear in a Message because the provider needs it as conversation input. The same text may also appear in an Event::UserMessage because the framework wants to record that the run started from that input. The message owns the conversation-facing version. The event owns the audit-facing version.

The envelope is different again. It will not own the whole conversation, and it will not own the whole audit trail. It will only describe one model decision at one moment: answer, call a tool, ask for clarification, or whatever shape we choose later. If that decision matters to the history of the run, the framework can also record it as an event.

That leaves Event with a narrower job. It does not define the conversation state we send back to the model, and it does not define the model contract. It records what the framework did. The event log is just the place where those records are written down.

5.3 Tagged Enums

The Event enum uses a serde attribute we have not used before:

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

#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Event {
    UserMessage { content: String },
    ModelResponse { content: String },
    FinalAnswer { content: String },
}

The tag = "kind" part tells serde to put the enum variant name into a JSON field named kind. The rename_all = "snake_case" part turns Rust's variant names into snake-case JSON names.

So this event:

Event::UserMessage {
    content: "hi".into(),
}

serializes like this:

{"kind":"user_message","content":"hi"}

This is called an internally tagged enum. The tag lives inside the object, next to the fields of the variant. For an event log, that shape is easy to scan and easy to replay. Every line says what kind of event it is before we look at its content.

The test makes that contract explicit:

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

#[test]
fn event_user_message_serializes_with_snake_case_kind_tag() {
    let event = Event::UserMessage {
        content: "hi".into(),
    };

    let json = serde_json::to_string(&event).expect("event should serialize");

    assert_eq!(json, r#"{"kind":"user_message","content":"hi"}"#);
}

This is a small test, but it protects the log format. If we change the serde attribute later, this test will tell us that the public shape changed.

A quick reminder about .expect()

Chapter 4 touched this when we discussed .unwrap(). In a test, .expect("event should serialize") is a reasonable choice when the value was created by the test itself and serialization is expected to succeed. If that assumption is wrong, the test should fail, and the message inside expect() tells us which assumption failed. In framework code, recoverable errors should usually be returned instead. We will do that in the next section.

5.4 The Event Log Error Type

Writing and reading a log can fail. Serialization can fail. Deserialization can fail. File I/O can fail.

Chapter 4 introduced ProviderError, which had one variant: the mock provider could run out of scripted responses. EventLogError has to cover more than one source of failure. The event log touches both files and JSON, so the error type needs to represent both I/O errors and serde JSON errors:

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

#[derive(Debug)]
pub enum EventLogError {
    Io(io::Error),
    Serde(serde_json::Error),
}

This error type is still small, but it is the first error type in the book with a real source chain. It can wrap an I/O error or a serde JSON error.

The enum derives Debug, which gives us a structural diagnostic representation for {:?}. That is useful when we want to inspect the value as a Rust value, including which enum variant it is and what it contains.

But an error also needs a readable message. For that, we implement Display. Display defines how a value is formatted with {}. For an error, that means the short explanatory sentence we want to show when the error is reported:

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

impl fmt::Display for EventLogError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            EventLogError::Io(e) => write!(f, "event log io error: {e}"),
            EventLogError::Serde(e) => write!(f, "event log json error: {e}"),
        }
    }
}

After Debug and Display, we can implement the standard Error trait. This marks EventLogError as a normal Rust error type. It also lets us expose the lower-level error that caused it:

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

impl std::error::Error for EventLogError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            EventLogError::Io(e) => Some(e),
            EventLogError::Serde(e) => Some(e),
        }
    }
}

The Error trait does not force us to override source(). Even an empty impl Error for EventLogError {} would make EventLogError usable in APIs that expect standard error values. This includes the CLI boundary we introduced in chapter 4: Result<(), Box<dyn Error>>. A boxed error can hold any owned value that implements the standard Error trait, so EventLogError can travel through that path.

We override source() for a different reason. It is not required for basic compatibility with Box<dyn Error>. It lets Rust error-reporting code ask, "what lower-level error caused this?" If EventLogError::Io wraps an io::Error, the source is that I/O error. If it wraps a serde_json::Error, the source is that serde error. That preserves the lower-level I/O or serde error that caused the event log error.

That chain matters because the framework can keep its own domain language while preserving the original cause.

5.5 From and the ? Operator

The chapter also implements From twice:

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

impl From<io::Error> for EventLogError {
    fn from(e: io::Error) -> Self {
        EventLogError::Io(e)
    }
}

impl From<serde_json::Error> for EventLogError {
    fn from(e: serde_json::Error) -> Self {
        EventLogError::Serde(e)
    }
}

This is what lets ? work inside functions that return Result<_, EventLogError>.

For example, write_event returns Result<(), EventLogError>, but serde_json::to_writer returns a serde JSON error, and writer.write_all returns an I/O error:

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

pub fn write_event(writer: &mut impl Write, event: &Event) -> Result<(), EventLogError> {
    serde_json::to_writer(&mut *writer, event)?;
    writer.write_all(b"\n")?;
    Ok(())
}

The ? operator does two things here:

  1. If the inner result is Ok, unwrap the success value and continue.
  2. If the inner result is Err, convert that error into EventLogError and return it from the current function.

That conversion step uses From. Without impl From<io::Error> for EventLogError, the write_all line could not use ? so smoothly. We would have to map the error by hand:

writer
    .write_all(b"\n")
    .map_err(EventLogError::Io)?;

That is not terrible for one line. But if every serde call and every I/O call needed its own map_err(...), the core logic would become harder to read. The From implementations let the function say what it is doing, while the type system still records how each lower-level error becomes an EventLogError.

This is one of the reasons Rust error types often have From implementations. They make error propagation honest without making every call site noisy.

5.6 Take a Write, Not a Path

The signature of write_event is the most important design choice in this chapter:

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

pub fn write_event(writer: &mut impl Write, event: &Event) -> Result<(), EventLogError>

The function does not take a file path, and that is deliberate. The core function's job is to format and write one event. It should not decide where the bytes go, or whether those bytes belong in a real file at all. The caller may want to write to a file, a memory buffer, a network stream, or a test fixture. All write_event needs is something writable.

Write is a trait from the standard library. A File implements Write. So does Vec<u8>. That means the same function works in production and in tests.

The tests use a memory buffer:

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

#[test]
fn write_event_appends_newline_terminated_json_line() {
    let mut buf: Vec<u8> = Vec::new();
    let event = Event::ModelResponse {
        content: "ok".into(),
    };

    write_event(&mut buf, &event).expect("write_event should succeed");

    assert_eq!(
        std::str::from_utf8(&buf).expect("utf8"),
        "{\"kind\":\"model_response\",\"content\":\"ok\"}\n"
    );
}

There is no temporary file here. There is no filesystem setup. The test gives write_event a Vec<u8> and checks the exact bytes.

This is the same style of boundary we used with Provider, but for I/O. The core code says what capability it needs. It does not over-specify the concrete thing that provides that capability.

5.7 JSONL

The log format is JSONL: one JSON value per line.

write_event serializes one event, then writes a newline:

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

serde_json::to_writer(&mut *writer, event)?;
writer.write_all(b"\n")?;

The newline matters. It means the log can be appended one event at a time and read one line at a time. A log with three events looks like this:

{"kind":"user_message","content":"hi"}
{"kind":"model_response","content":"mock: you said hi"}
{"kind":"final_answer","content":"mock: you said hi"}

This is not a JSON array. That is the point. We do not need to rewrite the whole file every time a new event arrives. We append one line.

That append-only shape is useful for an agent. If the process stops halfway through a run, the lines written so far still tell us what happened before the stop.

5.8 Reading Events Back

Writing is only half of the log. The framework also needs to load those records again. That is what the replay command will do: open an existing log file, parse each line back into an Event, and print the sequence so we can inspect what happened:

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

pub fn read_events(reader: impl BufRead) -> Result<Vec<Event>, EventLogError> {
    let mut events = Vec::new();
    for line in reader.lines() {
        let line = line?;
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        let event: Event = serde_json::from_str(trimmed)?;
        events.push(event);
    }
    Ok(events)
}

This function takes impl BufRead, not a path. That is the read-side version of the same design choice. The function's job is to parse events from buffered input. The caller owns where that input comes from.

The impl part matters for the same reason it mattered with impl Write. BufRead is a trait, not one concrete type. Writing reader: impl BufRead means "accept any concrete reader type that implements BufRead." A file wrapped in BufReader<File> can work. So can an in-memory byte slice in a test. We do not write reader: BufRead because a bare trait is not a concrete function parameter type.

There is also a longer generic form:

pub fn read_events<T: BufRead>(reader: T) -> Result<Vec<Event>, EventLogError>

For this function, that means almost the same thing as reader: impl BufRead. The generic form becomes more useful when we need to name the type T elsewhere in the signature, or when multiple parameters must have the same concrete type. Here we only need one readable input, so impl BufRead keeps the signature short.

The loop reads one line at a time. Blank lines are ignored. Every non-blank line must be valid JSON for an Event.

That last rule is strict on purpose. An event log is an audit record. If one line is malformed, we should know. We should not silently skip corrupted audit data. A malformed line could be text that is not JSON at all, JSON with a missing kind field, an unknown event kind, or an event whose fields do not match the expected shape.

The test makes that policy visible:

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

#[test]
fn read_events_errors_on_malformed_line() {
    let input: &[u8] = b"not json at all\n";

    let result = read_events(input);

    assert!(matches!(result, Err(EventLogError::Serde(_))));
}

This is a policy decision, not a universal rule. Later, some readers may choose to be lenient and skip bad entries. The log chooses the opposite. If the audit trail is broken, the program should say so.

5.9 Why Return a Vec<Event>?

read_events returns a Vec<Event>.

For small logs, that is the easiest shape. Tests can compare the whole result. The CLI can enumerate the whole sequence and print it. The caller gets a normal owned collection.

An iterator would use less memory for large logs. That may matter later. But right now, returning a Vec keeps the API simple and keeps the examples focused on events rather than streaming.

This is another time-bound decision. If the log grows large enough that reading the whole thing is a problem, we can add a streaming reader later. We do not need that complexity in the first event-log chapter.

5.10 The CLI Writes a Log

The CLI adds --log to chat:

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

Chat {
    /// The user message to send.
    message: String,
    /// Use the in-process mock provider (required while no real provider exists).
    #[arg(long)]
    mock: bool,
    /// Append JSONL event records to this file. When omitted, no events are recorded.
    #[arg(long, value_name = "PATH")]
    log: Option<PathBuf>,
},

The option is opt-in. There is no default log path yet because we do not have session directories yet. The caller chooses whether to record events and where to put them.

run_chat opens the file only when --log is present:

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

if let Some(path) = log {
    let file = OpenOptions::new().append(true).create(true).open(&path)?;
    let mut writer = BufWriter::new(file);

    write_event(
        &mut writer,
        &Event::UserMessage {
            content: message.clone(),
        },
    )?;
    write_event(
        &mut writer,
        &Event::ModelResponse {
            content: reply.content.clone(),
        },
    )?;
    write_event(
        &mut writer,
        &Event::FinalAnswer {
            content: reply.content,
        },
    )?;
}

if let Some(path) = log is a compact way to say: if the optional path is present, bind it to path and run this block. If it is None, do nothing.

The file is opened with OpenOptions because we need more control than File::open or File::create gives us. OpenOptions::new() starts with a blank set of file options. .append(true) says new bytes should be added to the end of the file instead of replacing the existing contents. .create(true) says the file should be created if it does not already exist. Finally, .open(&path)? tries to open the file with those options, and ? returns the I/O error if opening fails.

That combination matches an event log. A second run can append more events to the same file, and the first run does not need the file to exist in advance.

BufWriter wraps the file. It buffers small writes before sending them to the operating system. That is a common pattern when code writes several small chunks, like a JSON object followed by a newline, followed by another JSON object and another newline.

The order of events is also deliberate:

  1. user message
  2. model response
  3. final answer

Again, the last two are the same in this chapter. They will diverge later.

5.11 Replay

The CLI also adds a replay command:

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

Replay {
    /// Path to a JSONL event log file produced by `abcb chat --log`.
    path: PathBuf,
},

The implementation opens the file, wraps it in a BufReader, reads the events, and prints them:

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

fn run_replay(path: PathBuf) -> Result<(), Box<dyn Error>> {
    let file = File::open(&path)?;
    let reader = BufReader::new(file);
    let events = read_events(reader)?;

    for (index, event) in events.iter().enumerate() {
        let (kind, content) = match event {
            Event::UserMessage { content } => ("user_message", content),
            Event::ModelResponse { content } => ("model_response", content),
            Event::FinalAnswer { content } => ("final_answer", content),
        };
        println!("[{}] {kind}: {content}", index + 1);
    }

    Ok(())
}

This is the first place where the CLI pattern-matches on an event. It also introduces PathBuf. Chapter 2 used Path when the code only needed to borrow a path-like value. PathBuf is the owned version. Here, clap parses the command-line argument into an owned PathBuf, and run_replay receives that owned path. When we call File::open(&path), we borrow it because opening the file does not need to take ownership of the path value.

match event looks at the enum variant. Each branch extracts the content field. The result is a pair: the event kind as display text, and the content to print.

events.iter().enumerate() is one of the iterator adapters from Chapter 4. iter() borrows each event, so the item is &Event, not Event. enumerate() then yields (usize, &Event): a zero-based index and a borrowed event. The CLI prints index + 1 because humans usually expect logs to start at 1, not 0.

There is a small pattern detail hiding here. The & symbol has different meanings depending on where it appears. In an expression, on the right side of =, & creates a reference:

let n = 7;
let borrowed = &n; // borrowed: &i32

In a pattern, on the left side of =, & can destructure a reference:

let borrowed = &7;
let &value = borrowed; // value: i32

That second & does not create a new reference. It matches a reference and binds the value inside it. This works cleanly for i32 because i32 is Copy, so Rust can copy the integer out.

The same idea appears in iterator loops:

bytes.iter()              // Iterator<Item = &u8>
    .enumerate()          // Iterator<Item = (usize, &u8)>

for (index, &byte) in bytes.iter().enumerate() {
    // index: usize
    // byte: u8
}

Inside that loop body, byte is a plain u8, not &u8. Our Event loop does not write an explicit & pattern because Rust's match ergonomics handle the borrowed enum for us. Since event is a &Event, the content bindings in the match are borrowed too. We can print them without cloning the strings.

This command is not a full debugger. It is the first small read model over the log. The important thing is that the log is no longer just bytes on disk. The framework can write it and read it back.

5.12 What We Chose

This chapter makes a few more time-bound decisions.

First, the log is append-only JSONL. That makes writing simple and makes partial logs useful.

Second, logging is opt-in. There is no default location yet because the framework does not have persistent session directories yet.

Third, write_event takes &mut impl Write, and read_events takes impl BufRead. The core library owns the format. The caller owns the location.

Fourth, malformed log lines are errors. For an audit record, corruption should be loud.

Fifth, the errors are hand-written. We could use crates such as thiserror or anyhow, and many Rust projects do. For now, writing the implementations by hand lets us see exactly what the error type is doing. Once the framework has more error types, we can revisit whether another dependency is worth it.

A quick note on thiserror and anyhow

thiserror
helps define concrete error types with less boilerplate. It can derive much of the Display, Error, and source() work we wrote manually in this chapter. anyhow is different. It is often used near application edges when the caller does not need to match on one exact error enum and only needs to report that something failed. Both are useful, but using them now would hide the mechanics we are trying to learn.

5.13 What We Have So Far

At the end of this chapter, abcb can record the events from a mocked chat turn into a JSONL file and read those events back.

The core crate now has Event, EventLogError, write_event, and read_events. The CLI has chat --mock --log <PATH> and replay <PATH>.

This still does not make the model smarter. It makes the framework more inspectable. That is the right order. Once tools and structured model decisions arrive, we will already have a place to record what happened.

The agent is still small, but now it can leave a trail.

To be continued

Read more