The Evaluator

The evaluator walks the AST and executes each node. It's implemented in crates/patchwork-eval/src/eval.rs.

Synchronous Evaluation Model

The evaluator uses a synchronous model where all functions return Result<Value, Error>:

#![allow(unused)]
fn main() {
pub fn eval_expr(
    expr: &Expr,
    runtime: &mut Runtime,
    agent: Option<&AgentHandle>,
) -> Result<Value, Error>
}

Even think blocks—which communicate with an external LLM—block the calling thread until the response arrives. This keeps the evaluation logic simple: no async/await, no futures, just straightforward recursive descent.

Core Functions

The evaluator has three main entry points:

FunctionPurpose
eval_blockEvaluate a block of statements, returns last value
eval_statementEvaluate a single statement
eval_exprEvaluate an expression, returns a Value
graph TD
    eval_block --> eval_statement
    eval_statement --> eval_expr
    eval_statement --> eval_block
    eval_expr --> eval_expr
    eval_expr --> eval_block
    eval_expr -->|think| eval_think_block

Evaluating Blocks

A block creates a new scope, evaluates each statement, and returns the last value:

#![allow(unused)]
fn main() {
pub fn eval_block(
    block: &Block,
    runtime: &mut Runtime,
    agent: Option<&AgentHandle>,
) -> Result<Value, Error> {
    runtime.push_scope();
    let mut result = Value::Null;

    for stmt in &block.statements {
        result = eval_statement(stmt, runtime, agent)?;
    }

    runtime.pop_scope();
    Ok(result)
}
}

The push_scope/pop_scope calls ensure variables declared in the block don't leak out.

Evaluating Statements

Statements dispatch on the AST node type:

#![allow(unused)]
fn main() {
pub fn eval_statement(stmt: &Statement, ...) -> Result<Value, Error> {
    match stmt {
        Statement::VarDecl { pattern, init } => { ... }
        Statement::Expr(expr) => eval_expr(expr, runtime, agent),
        Statement::If { condition, then_block, else_block } => { ... }
        Statement::ForIn { var, iter, body } => { ... }
        Statement::While { condition, body } => { ... }
        Statement::Return(expr) => { ... }
        ...
    }
}
}

Variable Declaration

#![allow(unused)]
fn main() {
Statement::VarDecl { pattern, init } => {
    let value = match init {
        Some(expr) => eval_expr(expr, runtime, agent)?,
        None => Value::Null,
    };
    bind_pattern(pattern, value, runtime)?;
    Ok(Value::Null)
}
}

The pattern can be a simple identifier (var x = 1) or destructuring (var {name, age} = person).

Control Flow

if evaluates the condition and picks a branch:

#![allow(unused)]
fn main() {
Statement::If { condition, then_block, else_block } => {
    let cond_value = eval_expr(condition, runtime, agent)?;

    if cond_value.to_bool() {
        eval_block(then_block, runtime, agent)
    } else if let Some(else_blk) = else_block {
        eval_block(else_blk, runtime, agent)
    } else {
        Ok(Value::Null)
    }
}
}

for iterates over arrays or string lines:

#![allow(unused)]
fn main() {
Statement::ForIn { var, iter, body } => {
    let iter_value = eval_expr(iter, runtime, agent)?;

    let items = match iter_value {
        Value::Array(arr) => arr,
        Value::String(s) => s.lines().map(|l| Value::String(l.to_string())).collect(),
        other => return Err(Error::Runtime(...)),
    };

    for item in items {
        runtime.push_scope();
        runtime.define_var(var, item)?;
        eval_block(body, runtime, agent)?;
        runtime.pop_scope();
    }
    Ok(Value::Null)
}
}

Evaluating Expressions

Expressions return values. The big match covers all expression types:

#![allow(unused)]
fn main() {
pub fn eval_expr(expr: &Expr, ...) -> Result<Value, Error> {
    match expr {
        Expr::Identifier(name) => runtime.get_var(name).cloned()...,
        Expr::Number(s) => Ok(Value::Number(s.parse()?)),
        Expr::String(lit) => eval_string_literal(lit, runtime, agent),
        Expr::True => Ok(Value::Boolean(true)),
        Expr::Array(items) => { ... }
        Expr::Object(fields) => { ... }
        Expr::Binary { op, left, right } => eval_binary(...),
        Expr::Call { callee, args } => eval_call(...),
        Expr::Think(prompt_block) => eval_think_block(...),
        Expr::BareCommand { name, args } => eval_bare_command(...),
        ...
    }
}
}

String Interpolation

Strings can contain interpolations like "Hello ${name}":

#![allow(unused)]
fn main() {
fn eval_string_literal(lit: &StringLiteral, ...) -> Result<Value, Error> {
    let mut result = String::new();
    for part in &lit.parts {
        match part {
            StringPart::Text(s) => result.push_str(&process_escape_sequences(s)),
            StringPart::Interpolation(expr) => {
                let value = eval_expr(expr, runtime, agent)?;
                result.push_str(&value.to_string_value());
            }
        }
    }
    Ok(Value::String(result))
}
}

Binary Operations

Binary ops handle arithmetic, comparison, and assignment:

#![allow(unused)]
fn main() {
fn eval_binary(op: &BinOp, left: &Expr, right: &Expr, ...) -> Result<Value, Error> {
    // Assignment is special - evaluate RHS, store in LHS
    if let BinOp::Assign = op {
        let value = eval_expr(right, runtime, agent)?;
        if let Expr::Identifier(name) = left {
            runtime.set_var(name, value.clone())?;
            return Ok(value);
        }
    }

    let left_val = eval_expr(left, runtime, agent)?;
    let right_val = eval_expr(right, runtime, agent)?;

    match op {
        BinOp::Add => { /* number add or string concat */ }
        BinOp::Sub | BinOp::Mul | BinOp::Div => { /* numeric ops */ }
        BinOp::Eq | BinOp::NotEq | BinOp::Lt | BinOp::Gt => { /* comparisons */ }
        BinOp::And | BinOp::Or => { /* logical ops */ }
        ...
    }
}
}

String concatenation happens when either operand is a string:

#![allow(unused)]
fn main() {
BinOp::Add => match (&left_val, &right_val) {
    (Value::Number(a), Value::Number(b)) => Value::Number(a + b),
    (Value::String(a), Value::String(b)) => Value::String(format!("{}{}", a, b)),
    (Value::String(a), b) => Value::String(format!("{}{}", a, b.to_string_value())),
    (a, Value::String(b)) => Value::String(format!("{}{}", a.to_string_value(), b)),
    _ => return Err(...),
}
}

Builtin Functions

Builtins are handled specially in eval_call:

#![allow(unused)]
fn main() {
fn eval_builtin(name: &str, args: &[Value], runtime: &Runtime) -> Result<Value, Error> {
    match name {
        "print" => { runtime.print(...)?; Ok(Value::Null) }
        "len" => { /* array/string/object length */ }
        "read" => { /* read file contents */ }
        "write" => { /* write file contents */ }
        "json" => { /* parse JSON string */ }
        "cat" => { /* serialize to JSON */ }
        "keys" | "values" => { /* object introspection */ }
        "typeof" => { /* type name */ }
        _ => Err(Error::Runtime(format!("Unknown function: {}", name))),
    }
}
}

Exception Propagation

Exceptions use Error::Exception(Value) and propagate via Rust's ? operator:

#![allow(unused)]
fn main() {
// In eval_unary
UnOp::Throw => Err(Error::Exception(value))

// Callers automatically propagate:
let result = eval_expr(expr, runtime, agent)?;  // Exception bubbles up
}

This makes the control flow transparent—exceptions use the same mechanism as other errors.

Shell Commands

Bare commands like $ls -1 execute via the system shell:

#![allow(unused)]
fn main() {
fn eval_bare_command(name: &str, args: &[CommandArg], ...) -> Result<Value, Error> {
    let mut cmd_args = Vec::new();
    for arg in args {
        match arg {
            CommandArg::Literal(s) => cmd_args.push(s.to_string()),
            CommandArg::String(lit) => {
                let value = eval_string_literal(lit, runtime, agent)?;
                cmd_args.push(value.to_string_value());
            }
        }
    }
    exec_command(name, &cmd_args, runtime)
}

fn exec_command(name: &str, args: &[String], runtime: &Runtime) -> Result<Value, Error> {
    let output = Command::new(name)
        .args(args)
        .current_dir(runtime.working_dir())
        .output()?;

    if !output.status.success() {
        return Err(Error::Runtime(...));
    }

    Ok(Value::String(String::from_utf8_lossy(&output.stdout).into_owned()))
}
}

Commands run in the runtime's working directory, and stdout becomes the return value.

Think Blocks: The Bridge to LLM

Think blocks are where the evaluator meets the agent. This is covered in detail in the next chapter, but the key insight is:

#![allow(unused)]
fn main() {
Expr::Think(prompt_block) => eval_think_block(prompt_block, runtime, agent)
}

The evaluator treats think blocks like any other expression—call a function, get a value back. The complexity of LLM communication is hidden behind eval_think_block.