Avery Townsend7 min read

Teaching Agents to Write Maintainable Code

We rewrote our agent skill and built code quality metrics into the toolchain to help agents write better BAML

function chess_candidates(position: ChessPosition) -> ChessCandidate[] { while (from < 64) { let piece = board[from]; if (chess_color(piece, position.white)) { let kind = piece.to_lower_case(); if (kind == "p") { chess_pawn_candidates(position, from, candidates); } else { for (let direction in directions) { if ((kind != "b" || diagonal) && (kind != "r" || !diagonal)) { while (chess_inside(x, y)) {Cognitive complexity50Beforefunction candidates(self, from: int) -> ChessPly[] { let piece = self.board[from] ?? return []; match (piece.kind) { ChessKind.Pawn => self.pawn_moves(from), ChessKind.Knight => CHESS_KNIGHT_STEPS.flat_map(…), ChessKind.Bishop => CHESS_DIAGONALS.flat_map(…), ChessKind.Rook => CHESS_STRAIGHTS.flat_map(…), ChessKind.Queen => CHESS_RAYS.flat_map(…), ChessKind.King => CHESS_RAYS.flat_map(…), }}Cognitive complexity2After

Over the last few months we've been putting final polish on the BAML language and prepping for a 1.0 release. We've spent over a year designing the core language, and on that front consider BAML to be nearly complete. But, work remains in making sure BAML meshes well with coding agents, since they are a major part of engineering workflows today.

As it stands, agents can write BAML code that solves problems and passes tests. It is a real turing complete language with a compiler, bytecode VM, formatter, etc. The remaining problems with BAML lie not in the BAML language itself, but in the BAML code that the agents produce. We, and a lot of our testers, noticed that the quality of BAML code written by agents is noticeably worse than what the same agents produce in TypeScript or Rust. For instance, here's part of a chess move generator an agent (GPT-6.1-Sol with Codex) wrote with our current skill installed:

function chess_candidates(position: ChessPosition) -> ChessCandidate[] {
    let board = position.board;
    let candidates: ChessCandidate[] = [];
    let from = 0;
    while (from < 64) {
        let piece = board[from];
        if (chess_color(piece, position.white)) {
            let kind = piece.to_lower_case();
            if (kind == "p") {
                chess_pawn_candidates(position, from, candidates);
            } else {
                let directions = if (kind == "n") {
                    CHESS_KNIGHTS
                } else {
                    CHESS_RAYS
                };
                for (let direction in directions) {
                    let dx = direction[0];
                    let dy = direction[1];
                    let diagonal = dx != 0 && dy != 0;
                    if ((kind != "b" || diagonal) && (kind != "r" || !diagonal)) {
                        let x = from % 8 + dx;
                        let y = from / 8 + dy;
                        while (chess_inside(x, y)) {
                            let to = y * 8 + x;
                            if (chess_color(board[to], position.white)) {
                                break;
                            }
                            candidates.push(chess_candidate(from, to));
                            if (board[to] != "" || kind == "n" || kind == "k") {
                                break;
                            }
                            x += dx;
                            y += dy;
                        }
                    }
                }
                if (kind == "k") {
                    chess_castling_candidates(position, from, candidates);
                }
            }
        }
        from += 1;
    }
    candidates
}

This is a single function, nested seven levels deep. Chess pieces are stored as strings, so a knight is kind == "n", and a typo still compiles. It works, but it doesn't make good use of BAML's rich feature set and nobody would want to maintain code like this. We need some way to improve this, or else it is hard to justify the use of BAML when most people are writing their code with agents.

It stands to reason that existing languages don't suffer as much from this problem, as there is plenty of good code written by humans in the training corpus. Not to mention that the big AI labs are likely optimizing on code quality metrics in these languages. So, without BAML code in the training data, or the support of a large lab, our only real knob for improving code quality is to improve the skill that we ship with the BAML toolchain.

Our current skill mostly just lists out BAML's features: we have match, we have type unions, we have reflection, etc. It does have a small "best practices" section, but most entries just explain what a feature does, and say nothing about when or how to use it; an agent learns that enums exist without ever being told in what context it should use one.

This approach clearly wasn't working; out of ten Codex runs for this chess move generator in BAML, not a single agent opted to use an enum. So, we chose to rewrite most of our skill to focus on best practices and anti-patterns collected from human review of agent-generated BAML code.

What's in the new skill

The new skill describes how to (and how not to!) write BAML, instead of just what features it has. It adds 27 guidelines agents are told to always follow, each with a bad and a good example. These were modeled somewhat after the format of the C++ Core Guidelines. Here's a couple examples relevant to the chess move generator example:

Use a closed type for fixed sets of values. string accepts typos. Use an enum or a literal union.

// Bad
class Ticket {
    status: string,
}

// Good
enum Status {
    Open,
    Closed,
}

class Ticket {
    status: Status,
}

Use match instead of if chains. match is exhaustive, so a new variant is a compile error instead of a silent fallthrough.

// Bad
function hex(c: Color) -> string {
    if (c == Color.Red) {
        "#f00"
    } else {
        "#0f0"
    }
}

// Good
function hex(c: Color) -> string {
    match (c) {
        Color.Red => "#f00",
        Color.Green => "#0f0",
    }
}

With these new guidelines, nine of ten Codex runs with the new skill declared at least one enum in a chess move generator.

Measuring it

This new skill definitely made us feel better, but we wanted to see if we could find some quantitative measure of its effect. So, we brought in a few relatively simple metrics from the literature:

  • Cognitive complexity (SonarSource): every if, loop, and match adds one point, plus one for each level of nesting it sits in, and else adds one
  • Cyclomatic complexity (McCabe) is the number of independent paths through a function
  • NPath (Nejmeh) is the number of possible execution paths. Branches in a row multiply
  • Nesting is the deepest stack of ifs, loops, and lambdas
  • Exits counts each return and throw, plus finishing normally
Examples

Cognitive complexity

// Cognitive 9
function cost_bad(o: Order) -> int {
    if (o.paid) {
        if (o.in_stock) {
            if (o.express) {
                20
            } else {
                5
            }
        } else {
            0
        }
    } else {
        0
    }
}

// Cognitive 4
function cost_good(o: Order) -> int {
    if (!o.paid || !o.in_stock) {
        return 0;
    }
    if (o.express) {
        20
    } else {
        5
    }
}

Cyclomatic complexity

// Cyclomatic 5
function days_bad(month: string) -> int {
    if (month == "jan") {
        31
    } else if (month == "feb") {
        28
    } else if (month == "mar") {
        31
    } else if (month == "apr") {
        30
    } else {
        0
    }
}

// Cyclomatic 2
const DAYS = { "jan": 31, "feb": 28, "mar": 31, "apr": 30 };

function days_good(month: string) -> int {
    DAYS.get(month) ?? 0
}

NPath

// NPath 8
function badges_bad(o: Order) -> string[] {
    let badges: string[] = [];
    if (o.paid) {
        badges.push("paid");
    }
    if (o.in_stock) {
        badges.push("in stock");
    }
    if (o.express) {
        badges.push("express");
    }
    badges
}

// NPath 1
function badges_good(o: Order) -> string[] {
    [
        Badge { name: "paid", on: o.paid },
        Badge { name: "in stock", on: o.in_stock },
        Badge { name: "express", on: o.express },
    ]
        .filter((b) -> {
            b.on
        })
        .map((b) -> {
            b.name
        })
}

Nesting

// Nesting 3
function fragile_bad(carts: Cart[]) -> string[] {
    let names: string[] = [];
    for (let cart in carts) {
        for (let item in cart.items) {
            if (item.fragile) {
                names.push(item.name);
            }
        }
    }
    names
}

// Nesting 1
function fragile_good(carts: Cart[]) -> string[] {
    carts
        .flat_map((cart) -> {
            cart.items
        })
        .filter((item) -> {
            item.fragile
        })
        .map((item) -> {
            item.name
        })
}

Exits

// Exits 4
function label_bad(status: Status) -> string {
    if (status == Status.Open) {
        return "open";
    }
    if (status == Status.Paused) {
        return "paused";
    }
    return "closed";
}

// Exits 1
function label_good(status: Status) -> string {
    match (status) {
        Status.Open => "open",
        Status.Paused => "paused",
        Status.Closed => "closed",
    }
}

No metrics will ever objectively measure code quality, but we felt that these metrics encouraged the type of BAML code that we wanted to see: preference for match statements over if statements, no gigantic functions, a functional style, etc.

We built these into the toolchain with the baml codequality command. By default it prints a summary of the project, and --files and --functions break it down further. On the chess project above:

$ baml codequality --functions
Functions, worst cognitive first
  cognitive  cyclomatic  nesting  npath  exits  lines  function                   location
         50          17        7     59      1     46  chess_candidates           baml_src/chess_impl.baml:218
         44          22        5  1,472      5     51  chess_attacked             baml_src/chess_impl.baml:156

The Eval

With a way to quantify the code quality (or at least a metric related to it), we ran an eval. We had agents (GPT-6.1-Sol with Codex) implement four program skeletons in BAML, each with a public test suite for the agents to work against. Two are routine algorithmic problems:

  • a POSIX extended regex engine
  • chess move generation

And, the other two are intended to be more practical and make use of the standard library:

  • the RealWorld "Conduit" blogging API
  • next-departure lookups over GTFS transit feeds

We ran each task ten times for each of four different skill treatments:

  1. No skill provided.
  2. The old skill
  3. The new skill (with guidelines)
  4. The new skill, plus an instruction to run baml codequality while working

Results

Cognitive complexity

Cognitive complexity of the worst function in each run, tick at the mean. Lower scores are simpler by this metric.
  • No skill
  • Old skill
  • New skill
  • New skill + codequality
Regex
050100
Chess
0100200300
Conduit
0100200300
GTFS
0204060

Each further treatment clearly improves the cognitive complexity of the final outcome. But, some tasks are more highly affected than others. GTFS barely changed between the old and new skills, until we brought in baml codequality.

Cyclomatic complexity

Cyclomatic complexity of the worst function in each run, tick at the mean. Lower scores are simpler by this metric.
  • No skill
  • Old skill
  • New skill
  • New skill + codequality
Regex
0204060
Chess
0204060
Conduit
050100150
GTFS
010203040

For Chess and Conduit, we see the same clear step-by-step improvement in cyclomatic complexity that we saw with cognitive complexity. Incremental improvement is less clear on Regex and GTFS, but the final new skill including codequality improved the average in every category, though on Regex only because of two unusually low runs.

NPath

NPath of the worst function in each run, tick at the median, log scale. Lower scores are simpler by this metric.
  • No skill
  • Old skill
  • New skill
  • New skill + codequality
Regex
110010K1M100M
Chess
110010K1M100M
Conduit
1100K10B1e+151e+20
GTFS
110010K1M100M

For all but GTFS we see clear iterative improvements in scores with each further treatment, and the final runs with codequality always come out on top.

Nesting

Nesting of the worst function in each run, tick at the mean. Lower scores are simpler by this metric.
  • No skill
  • Old skill
  • New skill
  • New skill + codequality
Regex
0246
Chess
02468
Conduit
02468
GTFS
0246

Just as with cognitive complexity, there is a clear decrease in nesting as we introduce further treatments, and the final runs with the new skill alongside codequality score the best.

Exits

Exits of the worst function in each run, tick at the mean. Lower scores are simpler by this metric.
  • No skill
  • Old skill
  • New skill
  • New skill + codequality
Regex
051015
Chess
051015
Conduit
010203040
GTFS
0510

Exits definitely show the weakest trend, and are the only metric where the runs for the new skill with codequality are not consistently better than other treatments.

Conclusion

By these metrics, the skill improvements and baml codequality definitely had a measurable effect on the code. The worst function beats the old skill on cognitive complexity, cyclomatic complexity, NPath, and nesting on all four tasks.

Whether or not code quality truly increased is up to your trust in the metrics. It seems clear that some of the results are heavily swayed by the specific algorithm or program that you are implementing. For instance, the number of exits did not seem to be very affected by our skill changes in the GTFS runs at all, whereas we saw drastic changes in other programs. An agent may also make decisions that tend to decrease one metric while increasing another or vice versa.

At best these metrics are a proxy for code quality. A metric going up or down a few points is not necessarily good or bad. And, it is possible agents hill-climbed these metrics (even though the instructions specifically said not to). A lot of improvement on these metrics can boil down to just splitting large functions into multiple smaller functions. After all, you can decrease the average number of branches per function if you just split the branches between functions.

The stronger evidence for us is anecdotal. We chose these metrics specifically because we thought they would encourage more of the style of code that we wanted to read. And, when we read the code it just seemed overall better. Here's the same chess logic from an agent run using the new skill:

enum ChessKind {
    Pawn,
    Knight,
    Bishop,
    Rook,
    Queen,
    King,
}

const CHESS_DIAGONALS = [ChessStep { file: -1, rank: -1 }, ...];
const CHESS_STRAIGHTS = [ChessStep { file: -1, rank: 0 }, ...];
const CHESS_RAYS = CHESS_DIAGONALS.concat(CHESS_STRAIGHTS);

function candidates(self, from: int) -> ChessPly[] {
    let piece = self.board[from]
        ?? return [];
    if (piece.side != self.side) {
        return [];
    }
    match (piece.kind) {
        ChessKind.Pawn => self.pawn_moves(from),
        ChessKind.Knight => {
            CHESS_KNIGHT_STEPS.flat_map((step) -> {
                self.ray_moves(from, step, false)
            })
        },
        ChessKind.Bishop => {
            CHESS_DIAGONALS.flat_map((step) -> {
                self.ray_moves(from, step, true)
            })
        },
        ChessKind.Rook => {
            CHESS_STRAIGHTS.flat_map((step) -> {
                self.ray_moves(from, step, true)
            })
        },
        ChessKind.Queen => {
            CHESS_RAYS.flat_map((step) -> {
                self.ray_moves(from, step, true)
            })
        },
        ChessKind.King => {
            CHESS_RAYS
                .flat_map((step) -> {
                    self.ray_moves(from, step, false)
                })
                .concat(self.castle(from, true))
                .concat(self.castle(from, false))
        },
    }
}

function legal_moves(self) -> Move[] {
    CHESS_SQUARES
        .flat_map((from) -> {
            self.candidates(from)
        })
        .filter((ply) -> {
            self.after(ply).king_safe()
        })
        .map((ply) -> {
            ply.external()
        })
}

The difference from the code that was written with the old skill is night and day. The new code makes good use of BAML's features and type system: piece kinds are an enum, the movement directions are constant arrays, a single ray_moves function handles both sliding pieces and one-step pieces, etc.

Unfortunately, we can't put a number on our taste, or our feelings about the code. But, we do feel that code written with the new skill tends to be strictly of higher quality, and much closer to what we would've written by hand.

Try it

If you'd like to try it out, the new skill, including the baml codequality hint, has shipped in the latest version of BAML!

Get access to the BAML stack.

The stack is in private beta. Book a 45-minute call with the founders, and we'll set you up.