Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Sage is a programming language where agents are first-class citizens.

Instead of building agents using Python frameworks like LangChain or CrewAI, you write agents as naturally as you write functions. Agents, their state, and their interactions are semantic primitives baked into the compiler and runtime.

agent Researcher {
    topic: String

    on start {
        let summary = try infer(
            "Write a concise 2-sentence summary of: {self.topic}"
        );
        emit(summary);
    }

    on error(e) {
        emit("Research unavailable");
    }
}

agent Coordinator {
    on start {
        let r1 = spawn Researcher { topic: "quantum computing" };
        let r2 = spawn Researcher { topic: "CRISPR gene editing" };

        let s1 = try await r1;
        let s2 = try await r2;

        print(s1);
        print(s2);
        emit(0);
    }

    on error(e) {
        print("A researcher failed");
        emit(1);
    }
}

run Coordinator;

Why Sage?

Agents as primitives, not patterns. Most agent frameworks are libraries that impose patterns on top of a general-purpose language. Sage makes agents a first-class concept — the compiler understands what an agent is, what state it holds, and how agents communicate.

Type-safe LLM integration. The infer expression lets you call LLMs with structured output. The type system ensures you handle inference results correctly.

Compiles to native binaries. Sage compiles to Rust, then to native code. Your agent programs are fast, self-contained binaries with no runtime dependencies.

Concurrent by default. Spawned agents run concurrently. The runtime handles scheduling and message passing.

Built-in testing with LLM mocking. Test your agents with deterministic mocks — no network calls, fast feedback, reliable CI.

What You’ll Learn

This guide covers:

  1. Getting Started — Install Sage and write your first program
  2. Language Guide — Syntax, types, and control flow
  3. Agents — State, handlers, spawning, and messaging
  4. LLM Integration — Using infer to call language models
  5. Tools — Built-in tools like HTTP for external services
  6. Testing — Write tests with first-class LLM mocking
  7. Reference — CLI commands, environment variables, error codes

Let’s get started with installation.

Installation

Prerequisites

Sage requires a C linker and OpenSSL headers for compilation. Rust is not required.

macOS:

xcode-select --install

Debian/Ubuntu:

sudo apt install gcc libssl-dev

Fedora/RHEL:

sudo dnf install gcc openssl-devel

Arch:

sudo pacman -S gcc openssl

Install Sage

Homebrew (macOS)

brew install sagelang/sage/sage

Quick Install (macOS/Linux)

curl -fsSL https://raw.githubusercontent.com/sagelang/sage/main/scripts/install.sh | bash

Cargo (if you have Rust)

cargo install sage-lang

Nix

nix profile install github:sagelang/sage

Verify Installation

sage --version

You should see output like:

sage 0.4.x

Next Steps

Now that Sage is installed, let’s write your first program: Hello World.

Hello World

Let’s write the simplest possible Sage program.

Create a File

Create a file called hello.sg:

agent Main {
    on start {
        print("Hello from Sage!");
        emit(0);
    }
}

run Main;

Run It

sage run hello.sg

Output:

Hello from Sage!
0

What’s Happening?

Let’s break down this program:

  1. agent Main { ... } — Declares an agent named Main. Agents are the basic unit of computation in Sage.

  2. on start { ... } — The start handler runs when the agent is spawned. Every agent needs at least one handler.

  3. print("Hello from Sage!") — Prints a message to the console.

  4. emit(0) — Emits a value, signaling that the agent has finished. The emitted value becomes the agent’s result.

  5. run Main — Tells the compiler which agent to start. Every Sage program needs exactly one run statement.

Build a Binary

Instead of running directly, you can compile to a standalone binary:

sage build hello.sg -o out/
./out/hello/hello

The binary is self-contained — no Sage installation needed to run it.

Next Steps

Now let’s write something more interesting: Your First Agent.

Your First Agent

Let’s build an agent that does something useful — fetching information from an LLM.

Setup

First, set your OpenAI API key:

export SAGE_API_KEY="your-openai-api-key"

Or create a .env file in your project directory:

SAGE_API_KEY=your-openai-api-key

The Program

Create researcher.sg:

agent Researcher {
    topic: String

    on start {
        let summary = try infer(
            "Write a concise 2-sentence summary of: {self.topic}"
        );
        print(summary);
        emit(summary);
    }

    on error(e) {
        emit("Research failed");
    }
}

agent Main {
    on start {
        let r = spawn Researcher { topic: "the Rust programming language" };
        let result = try await r;
        print("Research complete!");
        emit(0);
    }

    on error(e) {
        print("Something went wrong");
        emit(1);
    }
}

run Main;

Run It

sage run researcher.sg

Output (will vary based on LLM response):

Rust is a systems programming language focused on safety, concurrency, and performance. It achieves memory safety without garbage collection through its ownership system.
Research complete!
0

What’s Happening?

  1. topic: String — The Researcher agent has a field called topic. Fields are the agent’s state, initialized when spawned.

  2. try infer("...") — Calls the LLM with the given prompt. The {self.topic} syntax interpolates the agent’s field into the prompt. The try propagates errors to on error.

  3. on error(e) — Handles errors from try expressions. Without this, the agent would panic on failure.

  4. spawn Researcher { topic: "..." } — Creates a new Researcher agent with the given field value.

  5. try await r — Waits for the agent to emit its result. The spawned agent runs concurrently until awaited.

Multiple Agents

Let’s spawn multiple researchers in parallel:

agent Researcher {
    topic: String

    on start {
        let summary = try infer(
            "One sentence about: {self.topic}"
        );
        emit(summary);
    }

    on error(e) {
        emit("Research unavailable");
    }
}

agent Main {
    on start {
        let r1 = spawn Researcher { topic: "quantum computing" };
        let r2 = spawn Researcher { topic: "machine learning" };
        let r3 = spawn Researcher { topic: "blockchain" };

        // All three run concurrently
        let s1 = try await r1;
        let s2 = try await r2;
        let s3 = try await r3;

        print(s1);
        print(s2);
        print(s3);
        emit(0);
    }

    on error(e) {
        emit(1);
    }
}

run Main;

The three Researcher agents run concurrently, making parallel LLM calls.

Next Steps

Now that you’ve built your first agent, explore the Language Guide to learn more about Sage’s syntax and features.

Basic Syntax

Sage syntax is designed to be familiar to developers coming from Rust, TypeScript, or Go.

Comments

// Single-line comment

/*
   Multi-line comment
   (not yet supported)
*/

Variables

Variables are declared with let:

let x = 42;
let name = "Sage";
let numbers = [1, 2, 3];

Variables are immutable by default. Reassignment creates a new binding:

let x = 1;
x = 2;  // Reassigns x

Operators

Arithmetic

let sum = 1 + 2;
let diff = 5 - 3;
let product = 4 * 2;
let quotient = 10 / 2;

Comparison

let eq = x == y;
let neq = x != y;
let lt = x < y;
let gt = x > y;
let lte = x <= y;
let gte = x >= y;

Logical

let and = a && b;
let or = a || b;
let not = !a;

String Concatenation

let greeting = "Hello, " ++ name ++ "!";

String Interpolation

Strings support interpolation with {identifier}:

let name = "World";
let greeting = "Hello, {name}!";  // "Hello, World!"

Semicolons

Following Rust conventions:

  • Required after: let, return, assignments, expression statements, run
  • Not required after: if/else, for, while blocks
let x = 1;           // semicolon required
if x > 0 {           // no semicolon after block
    print("positive");
}

Types

Sage has a simple but expressive type system.

Primitive Types

TypeDescriptionExample
Int64-bit signed integer42, -17
Float64-bit floating point3.14, -0.5
BoolBooleantrue, false
StringUTF-8 string"hello"
UnitNo value (like Rust’s ())

Compound Types

List<T>

Ordered collection of elements:

let numbers: List<Int> = [1, 2, 3];
let names: List<String> = ["Alice", "Bob"];
let empty: List<Int> = [];

Map<K, V>

Key-value collections:

let ages: Map<String, Int> = {"alice": 30, "bob": 25};
let alice_age = map_get(ages, "alice");  // Option<Int>

map_set(ages, "charlie", 35);
let has_bob = map_has(ages, "bob");      // true
let keys = map_keys(ages);               // List<String>

Tuples

Fixed-size heterogeneous collections:

let pair: (Int, String) = (42, "hello");
let first = pair.0;   // 42
let second = pair.1;  // "hello"

// Tuple destructuring
let (x, y) = pair;

// Three-element tuple
let triple: (Int, String, Bool) = (1, "test", true);

Option<T>

Optional values:

let some_value: Option<Int> = Some(42);
let no_value: Option<Int> = None;

// Pattern matching on Option
match some_value {
    Some(n) => print("Got: " ++ str(n)),
    None => print("Nothing"),
}

Result<T, E>

Success or error values:

let success: Result<Int, String> = Ok(42);
let failure: Result<Int, String> = Err("not found");

match success {
    Ok(value) => print("Value: " ++ str(value)),
    Err(msg) => print("Error: " ++ msg),
}

Fn(A, B) -> C

Function types for closures and higher-order functions:

let add: Fn(Int, Int) -> Int = |x: Int, y: Int| x + y;
let double: Fn(Int) -> Int = |x: Int| x * 2;

fn apply(f: Fn(Int) -> Int, x: Int) -> Int {
    return f(x);
}

let result = apply(double, 21);  // 42

User-Defined Types

Records

Define structured data with named fields:

record Point {
    x: Int,
    y: Int,
}

record Person {
    name: String,
    age: Int,
}

Construct records and access fields:

let p = Point { x: 10, y: 20 };
let sum = p.x + p.y;

let person = Person { name: "Alice", age: 30 };
print(person.name);

Enums

Define types with a fixed set of variants:

enum Status {
    Active,
    Inactive,
    Pending,
}

enum Direction {
    North,
    South,
    East,
    West,
}

Use enum variants directly:

let s = Active;
let d = North;

Enum Payloads

Enums can carry data:

enum Result {
    Ok(Int),
    Err(String),
}

enum Message {
    Text(String),
    Number(Int),
    Pair(Int, String),
}

// Construct variants with payloads
let success = Result::Ok(42);
let failure = Result::Err("not found");
let msg = Message::Pair(1, "hello");

Match Expressions

Pattern match on enums and other values:

fn describe(s: Status) -> String {
    return match s {
        Active => "running",
        Inactive => "stopped",
        Pending => "waiting",
    };
}

Match on integers with a wildcard:

fn classify(n: Int) -> String {
    return match n {
        0 => "zero",
        1 => "one",
        _ => "many",
    };
}

Pattern Matching with Payloads

Bind payload values in match arms:

fn unwrap_result(r: Result) -> String {
    return match r {
        Ok(value) => str(value),
        Err(msg) => msg,
    };
}

fn handle_message(m: Message) -> String {
    return match m {
        Text(s) => s,
        Number(n) => str(n),
        Pair(n, s) => str(n) ++ ": " ++ s,
    };
}

The compiler checks that all variants are covered (exhaustiveness checking).

Constants

Define compile-time constants:

const MAX_RETRIES: Int = 3;
const DEFAULT_NAME: String = "anonymous";

Agent Types

Agent<T>

A handle to a spawned agent that will emit a value of type T:

agent Worker {
    on start {
        emit(42);
    }
}

agent Main {
    on start {
        let w: Agent<Int> = spawn Worker {};
        let result: Int = try await w;
        emit(result);
    }

    on error(e) {
        emit(0);
    }
}

run Main;

Inferred<T>

The result of an LLM inference call:

let summary = try infer("Summarize: {topic}");

Inferred<T> can be used anywhere T is expected — the type coerces automatically.

Type Inference

Sage infers types when possible:

let x = 42;              // Int
let name = "Sage";       // String
let list = [1, 2, 3];    // List<Int>

Explicit annotations are required for:

  • Function parameters
  • Agent state fields
  • Closure parameters
  • Ambiguous cases

Type Annotations

Use : Type syntax:

let x: Int = 42;
let items: List<String> = [];

fn double(n: Int) -> Int {
    return n * 2;
}

agent Worker {
    count: Int

    on start {
        emit(self.count * 2);
    }
}

Functions

Functions in Sage are defined at the top level and can be called from anywhere.

Defining Functions

fn greet(name: String) -> String {
    return "Hello, " ++ name ++ "!";
}

fn add(a: Int, b: Int) -> Int {
    return a + b;
}

Calling Functions

let message = greet("World");
let sum = add(1, 2);

Return Types

All functions must declare their return type:

fn double(n: Int) -> Int {
    return n * 2;
}

fn print_message(msg: String) -> Unit {
    print(msg);
    return;
}

Use Unit for functions that don’t return a meaningful value.

Recursion

Functions can call themselves:

fn factorial(n: Int) -> Int {
    if n <= 1 {
        return 1;
    }
    return n * factorial(n - 1);
}

fn fibonacci(n: Int) -> Int {
    if n <= 1 {
        return n;
    }
    return fibonacci(n - 1) + fibonacci(n - 2);
}

Closures

Sage supports first-class functions and closures:

// Closure with typed parameters
let add = |x: Int, y: Int| x + y;

// Empty parameter closure
let get_value = || 42;

// Multi-statement closure with block
let greet = |name: String| {
    let msg = "Hello, " ++ name ++ "!";
    return msg;
};

Closure parameters require explicit type annotations.

Function Types

Use Fn(A, B) -> C to describe function types:

fn apply(f: Fn(Int) -> Int, x: Int) -> Int {
    return f(x);
}

let double = |x: Int| x * 2;
let result = apply(double, 21);  // 42

Higher-Order Functions

Functions can return closures:

fn make_multiplier(n: Int) -> Fn(Int) -> Int {
    return |x: Int| x * n;
}

let triple = make_multiplier(3);
let result = triple(10);  // 30

Fallible Functions

Functions that can fail are marked with fails:

fn risky_operation() -> Int fails {
    let value = try infer("Give me a number");
    return parse_int(value);
}

Callers must handle errors with try or catch:

agent Main {
    on start {
        let result = try risky_operation();
        emit(result);
    }

    on error(e) {
        emit(0);
    }
}

run Main;

Built-in Functions

Sage provides several built-in functions:

FunctionSignatureDescription
print(String) -> UnitPrint to console
str(T) -> StringConvert any value to string
len(List<T>) -> IntGet list or map length
push(List<T>, T) -> List<T>Append to list
join(List<String>, String) -> StringJoin strings
int_to_str(Int) -> StringConvert int to string
str_contains(String, String) -> BoolCheck substring
sleep_ms(Int) -> UnitSleep for milliseconds
map_get(Map<K,V>, K) -> Option<V>Get value from map
map_set(Map<K,V>, K, V) -> UnitSet key-value in map
map_has(Map<K,V>, K) -> BoolCheck if key exists
map_delete(Map<K,V>, K) -> UnitRemove key from map
map_keys(Map<K,V>) -> List<K>Get all keys as list
map_values(Map<K,V>) -> List<V>Get all values as list

Example

fn summarize_list(items: List<String>) -> String {
    let count = len(items);
    let joined = join(items, ", ");
    return "Found " ++ str(count) ++ " items: " ++ joined;
}

agent Main {
    on start {
        let result = summarize_list(["apple", "banana", "cherry"]);
        print(result);
        emit(0);
    }
}

run Main;

Output:

Found 3 items: apple, banana, cherry

Control Flow

Sage provides standard control flow constructs.

If/Else

if x > 0 {
    print("positive");
} else if x < 0 {
    print("negative");
} else {
    print("zero");
}

Conditions must be Bool — no implicit truthy/falsy coercion.

For Loops

Iterate over lists:

let numbers = [1, 2, 3, 4, 5];

for n in numbers {
    print(str(n));
}

With index tracking:

let names = ["Alice", "Bob", "Charlie"];
let i = 0;

for name in names {
    print(str(i) ++ ": " ++ name);
    i = i + 1;
}

Iterate over maps with tuple destructuring:

let scores = {"alice": 100, "bob": 85, "charlie": 92};

for (name, score) in scores {
    print(name ++ ": " ++ str(score));
}

While Loops

let count = 0;

while count < 5 {
    print(str(count));
    count = count + 1;
}

Infinite Loops

Use loop for indefinite iteration, and break to exit:

loop {
    let input = get_input();
    if input == "quit" {
        break;
    }
    process(input);
}

This is particularly useful for agents that process messages:

agent Worker receives WorkerMsg {
    on start {
        loop {
            let msg: WorkerMsg = receive();
            match msg {
                Shutdown => break,
                Task => process_task(),
            }
        }
        emit(0);
    }
}

Early Return

Use return to exit a function early:

fn find_first_positive(numbers: List<Int>) -> Int {
    for n in numbers {
        if n > 0 {
            return n;
        }
    }
    return -1;
}

Example: FizzBuzz

fn fizzbuzz(n: Int) -> String {
    if n % 15 == 0 {
        return "FizzBuzz";
    }
    if n % 3 == 0 {
        return "Fizz";
    }
    if n % 5 == 0 {
        return "Buzz";
    }
    return str(n);
}

agent Main {
    on start {
        let i = 1;
        while i <= 20 {
            print(fizzbuzz(i));
            i = i + 1;
        }
        emit(0);
    }
}

run Main;

What Are Agents?

Agents are the core abstraction in Sage — autonomous units of computation with state and behavior.

The Mental Model

Think of an agent as a small, focused worker:

  • It has state (its private fields)
  • It responds to events (start, messages, errors)
  • It can spawn other agents
  • It emits a result when done
agent Worker {
    task: String              // State

    on start {                // Event handler
        let result = do_work(self.task);
        emit(result);         // Result
    }
}

Why Agents?

vs. Functions

Functions are synchronous and stateless. Agents are asynchronous and maintain state across their lifetime.

vs. Objects

Objects bundle state and methods. Agents bundle state and event handlers — they react to events rather than being called directly.

vs. Threads

Threads are low-level and share memory. Agents are high-level and communicate through messages. No locks, no races.

Agent Lifecycle

  1. Spawn — Agent is created with initial state
  2. Start — The on start handler runs
  3. Running — Agent can receive messages, spawn other agents
  4. Emit — Agent produces its result
  5. Done — Agent terminates
spawn Worker { task: "..." }
        │
        ▼
    ┌───────┐
    │ start │ ─── on start { ... }
    └───┬───┘
        │
        ▼
    ┌────────┐
    │running │ ─── on message { ... }
    └───┬────┘
        │
        ▼
    ┌──────┐
    │ emit │ ─── emit(value)
    └──────┘

A Complete Example

agent Counter {
    initial: Int

    on start {
        let count = self.initial;
        let i = 0;
        while i < 5 {
            count = count + 1;
            i = i + 1;
        }
        emit(count);
    }
}

agent Main {
    on start {
        let c1 = spawn Counter { initial: 0 };
        let c2 = spawn Counter { initial: 100 };

        let r1 = try await c1;  // 5
        let r2 = try await c2;  // 105

        print("Results: " ++ str(r1) ++ ", " ++ str(r2));
        emit(0);
    }

    on error(e) {
        print("A counter failed");
        emit(1);
    }
}

run Main;

Both counters run concurrently. The main agent waits for both results.

Next

Agent State

Agent fields are private state. They’re initialized when the agent is spawned and can be accessed throughout the agent’s lifetime.

Declaring Fields

Agent state uses record-style field declarations:

agent Person {
    name: String
    age: Int
}

Fields must have explicit type annotations.

Initializing Fields

When spawning an agent, provide values for all fields:

let p = spawn Person { name: "Alice", age: 30 };

Missing fields cause a compile error:

// Error: missing field `age` in spawn
let p = spawn Person { name: "Alice" };

Accessing Fields

Use self.fieldName inside the agent:

agent Greeter {
    name: String

    on start {
        print("Hello, " ++ self.name ++ "!");
        emit(0);
    }
}

Fields Are Immutable

Fields cannot be reassigned after initialization:

agent Counter {
    count: Int

    on start {
        // This won't work — fields are immutable
        // self.count = self.count + 1;

        // Use a local variable instead
        let count = self.count;
        count = count + 1;
        emit(count);
    }
}

Entry Agent Fields

The entry agent (the one in run) cannot have required fields:

// Error: entry agent cannot have required fields
agent Main {
    config: String

    on start {
        emit(0);
    }
}

run Main;  // How would we provide `config`?

Design Pattern: Configuration

Use fields to configure agent behavior:

agent Fetcher {
    url: String
    timeout: Int

    on start {
        // Use self.url and self.timeout
        emit("done");
    }
}

agent Main {
    on start {
        let f1 = spawn Fetcher {
            url: "https://api.example.com/a",
            timeout: 5000
        };
        let f2 = spawn Fetcher {
            url: "https://api.example.com/b",
            timeout: 3000
        };

        let r1 = try await f1;
        let r2 = try await f2;
        emit(0);
    }

    on error(e) {
        emit(1);
    }
}

run Main;

Event Handlers

Agents respond to events through handlers. Each handler runs when its corresponding event occurs.

on start

Runs when the agent is spawned:

agent Worker {
    on start {
        print("Worker started!");
        emit(42);
    }
}

Every agent must have an on start handler — it’s where the agent’s main logic lives.

on error

Handles errors propagated by try:

agent Researcher {
    topic: String

    on start {
        let result = try infer("Summarize: {self.topic}");
        emit(result);
    }

    on error(e) {
        print("Research failed: " ++ e);
        emit("unavailable");
    }
}

When a try expression fails, control jumps to on error. Without an on error handler, the agent will panic.

Message Handling

For agents that receive messages, use the receives clause with receive():

enum Command {
    Ping,
    Shutdown,
}

agent Worker receives Command {
    on start {
        loop {
            let msg: Command = receive();
            match msg {
                Ping => print("Pong!"),
                Shutdown => break,
            }
        }
        emit(0);
    }
}

See Messaging for details.

Handler Order

  1. on start runs first, exactly once
  2. on error runs if a try expression fails
  3. After emit, the agent terminates

emit

The emit expression signals that the agent has produced its result:

agent Calculator {
    a: Int
    b: Int

    on start {
        let result = self.a + self.b;
        emit(result);  // Agent is done
    }
}

After emit:

  • The agent’s result is available to whoever awaited it
  • The agent proceeds to cleanup (on stop)
  • No more messages are processed

Emit Type Consistency

All emit calls in an agent must have the same type:

agent Example {
    on start {
        if condition {
            emit(42);      // Int
        } else {
            emit("error"); // Error: expected Int, got String
        }
    }
}

Handler Scope

Each handler has its own scope. Variables don’t persist between handlers:

agent Example {
    on start {
        let x = 42;
        // x is only visible here
        emit(0);
    }

    on error(e) {
        // x is not visible here
        // Use agent fields for persistent state
        emit(1);
    }
}

Use agent fields (accessed via self) for state that needs to persist.

Spawning & Awaiting

Agents are created with spawn and their results are retrieved with await.

spawn

Creates a new agent and returns a handle:

let worker = spawn Worker { task: "process data" };

The spawned agent starts running immediately and concurrently with the spawning agent.

Spawn Syntax

spawn AgentName { field1: value1, field2: value2 }

All fields must be provided:

agent Point {
    x: Int
    y: Int

    on start {
        emit(self.x + self.y);
    }
}

// Correct
let p = spawn Point { x: 10, y: 20 };

// Error: missing field `y`
let p = spawn Point { x: 10 };

Agent Handle Type

spawn returns an Agent<T> where T is the emit type:

agent Worker {
    on start {
        emit(42);  // Emits Int
    }
}

let w: Agent<Int> = spawn Worker {};

await

Waits for an agent to emit its result. Since agents can fail, await is a fallible operation that requires try:

let worker = spawn Worker {};
let result = try await worker;  // Blocks until Worker emits

Await Type

await returns the type that the agent emits:

agent StringWorker {
    on start {
        emit("done");
    }
}

agent Main {
    on start {
        let w = spawn StringWorker {};
        let result: String = try await w;
        print(result);
        emit(0);
    }

    on error(e) {
        emit(1);
    }
}

run Main;

Await Blocks

await suspends the current agent until the result is ready. Other agents continue running.

Concurrent Execution

Spawned agents run concurrently:

agent Sleeper {
    ms: Int

    on start {
        sleep_ms(self.ms);
        emit(self.ms);
    }
}

agent Main {
    on start {
        // All three start immediately
        let s1 = spawn Sleeper { ms: 100 };
        let s2 = spawn Sleeper { ms: 200 };
        let s3 = spawn Sleeper { ms: 300 };

        // Total time: ~300ms (not 600ms)
        let r1 = try await s1;
        let r2 = try await s2;
        let r3 = try await s3;

        emit(0);
    }

    on error(e) {
        emit(1);
    }
}

run Main;

Pattern: Fan-Out/Fan-In

Spawn multiple workers, await all results:

agent Researcher {
    topic: String

    on start {
        let result = try infer(
            "One sentence about: {self.topic}"
        );
        emit(result);
    }

    on error(e) {
        emit("Research failed");
    }
}

agent Coordinator {
    on start {
        // Fan out
        let r1 = spawn Researcher { topic: "AI" };
        let r2 = spawn Researcher { topic: "Robotics" };
        let r3 = spawn Researcher { topic: "Quantum" };

        // Fan in
        let s1 = try await r1;
        let s2 = try await r2;
        let s3 = try await r3;

        print(s1);
        print(s2);
        print(s3);
        emit(0);
    }

    on error(e) {
        print("A researcher failed");
        emit(1);
    }
}

run Coordinator;

Pattern: Pipeline

Chain agents together:

agent Step1 {
    input: String

    on start {
        let result = self.input ++ " -> step1";
        emit(result);
    }
}

agent Step2 {
    input: String

    on start {
        let result = self.input ++ " -> step2";
        emit(result);
    }
}

agent Main {
    on start {
        let s1 = spawn Step1 { input: "start" };
        let r1 = try await s1;

        let s2 = spawn Step2 { input: r1 };
        let r2 = try await s2;

        print(r2);  // "start -> step1 -> step2"
        emit(0);
    }

    on error(e) {
        emit(1);
    }
}

run Main;

Messaging

Agents can receive typed messages from other agents using the actor model pattern.

The receives Clause

An agent declares what type of messages it accepts using the receives clause:

enum WorkerMsg {
    Task,
    Ping,
    Shutdown,
}

agent Worker receives WorkerMsg {
    id: Int

    on start {
        // This agent can now receive WorkerMsg messages
        emit(0);
    }
}

Agents without a receives clause are pure spawn/await agents and cannot receive messages.

The receive() Expression

Inside an agent with a receives clause, use receive() to wait for a message:

agent Worker receives WorkerMsg {
    id: Int

    on start {
        let msg: WorkerMsg = receive();
        match msg {
            Task => print("Got a task"),
            Ping => print("Pinged"),
            Shutdown => print("Shutting down"),
        }
        emit(0);
    }
}

receive() blocks until a message arrives in the agent’s mailbox.

The send() Function

Send a message to a running agent using its handle. send is fallible (the agent might have terminated), so use try:

agent Main {
    on start {
        let w = spawn Worker { id: 1 };
        try send(w, Task);
        try send(w, Shutdown);
        try await w;
        emit(0);
    }

    on error(e) {
        emit(1);
    }
}

run Main;

send queues the message and returns immediately.

Long-Running Agents with loop

Combine receive() with loop for agents that process multiple messages:

agent Worker receives WorkerMsg {
    id: Int

    on start {
        loop {
            let msg: WorkerMsg = receive();
            match msg {
                Task => {
                    let result = try infer("Process a task");
                    print("Worker {self.id}: {result}");
                }
                Ping => {
                    print("Worker {self.id} is alive");
                }
                Shutdown => {
                    break;
                }
            }
        }
        emit(0);
    }

    on error(e) {
        print("Worker {self.id} failed: " ++ e);
        emit(1);
    }
}

Complete Example: Worker Pool

enum WorkerMsg {
    Task,
    Shutdown,
}

agent Worker receives WorkerMsg {
    id: Int

    on start {
        loop {
            let msg: WorkerMsg = receive();
            match msg {
                Task => {
                    let result = try infer("Summarise something interesting");
                    print("Worker {self.id}: {result}");
                }
                Shutdown => {
                    break;
                }
            }
        }
        emit(0);
    }

    on error(e) {
        print("Worker {self.id} failed");
        emit(1);
    }
}

agent Coordinator {
    on start {
        let w1 = spawn Worker { id: 1 };
        let w2 = spawn Worker { id: 2 };

        // Distribute tasks
        try send(w1, Task);
        try send(w2, Task);
        try send(w1, Task);
        try send(w2, Task);

        // Shut down workers
        try send(w1, Shutdown);
        try send(w2, Shutdown);

        // Wait for completion
        try await w1;
        try await w2;

        emit(0);
    }

    on error(e) {
        print("Coordination failed");
        emit(1);
    }
}

run Coordinator;

Type Safety

The compiler ensures type safety:

agent Worker receives WorkerMsg {
    on start {
        let msg: WorkerMsg = receive();
        emit(0);
    }
}

agent Main {
    on start {
        let w = spawn Worker {};
        try send(w, Task);       // OK - Task is a WorkerMsg variant
        try send(w, "hello");    // Error: expected WorkerMsg, got String
        emit(0);
    }

    on error(e) {
        emit(1);
    }
}

Messaging vs Awaiting

awaitsend / receive
DirectionGet final result from agentOngoing communication
BlockingYes, waits for agent to completesend returns immediately, receive blocks until message arrives
Use caseOne-shot tasksLong-running workers, event loops

Mailbox Semantics

  • Each agent has a bounded mailbox (128 messages by default)
  • When the mailbox is full, send blocks until space opens (backpressure)
  • Messages from a single sender arrive in order
  • Messages from multiple senders are interleaved (no global ordering)

Current Limitations

  • No receive_timeout in the language yet (available in runtime)
  • No broadcast channels (one-to-many messaging)
  • Error handling for closed channels needs its own RFC

The infer Expression

The infer expression is how Sage programs interact with large language models.

Basic Usage

Since LLM calls can fail (network errors, API errors), infer is a fallible operation that requires try:

agent Main {
    on start {
        let result = try infer("What is the capital of France?");
        print(result);  // "Paris" (or similar)
        emit(0);
    }

    on error(e) {
        print("LLM call failed: " ++ e);
        emit(1);
    }
}

run Main;

String Interpolation

Use {identifier} to include variables in prompts:

agent Researcher {
    topic: String

    on start {
        let summary = try infer(
            "Write a 2-sentence summary of: {self.topic}"
        );
        emit(summary);
    }

    on error(e) {
        emit("Research unavailable");
    }
}

Multiple interpolations:

let format = "JSON";
let topic = "climate change";

let result = try infer(
    "Output a {format} object with key facts about {topic}"
);

The Inferred<T> Type

infer returns Inferred<T>, which wraps the LLM’s response.

Inferred<T> coerces to T automatically:

let response = try infer("Hello!");
print(response);  // Works - Inferred<String> coerces to String

Structured Output

infer can return any type, including user-defined records:

record Summary {
    title: String,
    key_points: List<String>,
    sentiment: String,
}

agent Analyzer {
    topic: String

    on start {
        let result: Inferred<Summary> = try infer(
            "Analyze this topic and provide a structured summary: {self.topic}"
        );
        print("Title: " ++ result.title);
        print("Sentiment: " ++ result.sentiment);
        emit(result);
    }

    on error(e) {
        print("Analysis failed: " ++ e);
        emit(Summary { title: "Error", key_points: [], sentiment: "unknown" });
    }
}

The runtime automatically:

  1. Injects the expected schema into the prompt
  2. Parses the LLM’s response as JSON
  3. Retries with error feedback if parsing fails (configurable via SAGE_INFER_RETRIES)

This works with any OpenAI-compatible API, including Ollama.

Error Handling

Use try to propagate errors to the agent’s on error handler:

let result = try infer("prompt");

Or use catch to handle errors inline with a fallback:

let result = catch infer("prompt") {
    "fallback value"
};

Example: Multi-Step Reasoning

agent Reasoner {
    question: String

    on start {
        let step1 = try infer(
            "Break down this question into sub-questions: {self.question}"
        );

        let step2 = try infer(
            "Given these sub-questions: {step1}\n\nAnswer each one briefly."
        );

        let step3 = try infer(
            "Given the original question: {self.question}\n\n" ++
            "And these answers: {step2}\n\n" ++
            "Provide a final comprehensive answer."
        );

        emit(step3);
    }

    on error(e) {
        emit("Reasoning failed: " ++ e);
    }
}

agent Main {
    on start {
        let r = spawn Reasoner {
            question: "How do vaccines work and why are they important?"
        };
        let answer = try await r;
        print(answer);
        emit(0);
    }

    on error(e) {
        emit(1);
    }
}

run Main;

Concurrent Inference

Multiple infer calls can run concurrently via spawned agents:

agent Summarizer {
    text: String

    on start {
        let summary = try infer(
            "Summarize in one sentence: {self.text}"
        );
        emit(summary);
    }

    on error(e) {
        emit("Summary unavailable");
    }
}

agent Main {
    on start {
        let s1 = spawn Summarizer { text: "Long article about AI..." };
        let s2 = spawn Summarizer { text: "Long article about robotics..." };
        let s3 = spawn Summarizer { text: "Long article about space..." };

        // All three LLM calls happen concurrently
        let r1 = try await s1;
        let r2 = try await s2;
        let r3 = try await s3;

        print(r1);
        print(r2);
        print(r3);
        emit(0);
    }

    on error(e) {
        emit(1);
    }
}

run Main;

Configuration

Configure LLM behavior through environment variables.

Required

SAGE_API_KEY

Your OpenAI API key (or compatible provider):

export SAGE_API_KEY="sk-..."

Or in a .env file in your project directory:

SAGE_API_KEY=sk-...

Optional

SAGE_LLM_URL

Base URL for the LLM API. Defaults to OpenAI:

export SAGE_LLM_URL="https://api.openai.com/v1"

For local models (Ollama):

export SAGE_LLM_URL="http://localhost:11434/v1"

For other providers (Azure, Anthropic-compatible, etc.):

export SAGE_LLM_URL="https://your-provider.com/v1"

SAGE_MODEL

Which model to use. Default: gpt-4o-mini

export SAGE_MODEL="gpt-4o"

For Ollama:

export SAGE_MODEL="llama2"

SAGE_MAX_TOKENS

Maximum tokens per response. Default: 1024

export SAGE_MAX_TOKENS="2048"

SAGE_TIMEOUT_MS

Request timeout in milliseconds. Default: 30000 (30 seconds)

export SAGE_TIMEOUT_MS="60000"

Using .env Files

Sage automatically loads .env files from the current directory:

# .env
SAGE_API_KEY=sk-...
SAGE_MODEL=gpt-4o
SAGE_MAX_TOKENS=2048

Provider Examples

OpenAI (default)

export SAGE_API_KEY="sk-..."
# SAGE_LLM_URL defaults to OpenAI
export SAGE_MODEL="gpt-4o"

Ollama (local)

export SAGE_LLM_URL="http://localhost:11434/v1"
export SAGE_MODEL="llama2"
# No API key needed for local Ollama

Azure OpenAI

export SAGE_LLM_URL="https://your-resource.openai.azure.com/openai/deployments/your-deployment"
export SAGE_API_KEY="your-azure-key"
export SAGE_MODEL="gpt-4"

Other OpenAI-Compatible Providers

Any provider with an OpenAI-compatible API should work:

export SAGE_LLM_URL="https://api.together.xyz/v1"
export SAGE_API_KEY="your-key"
export SAGE_MODEL="meta-llama/Llama-3-70b-chat-hf"

Troubleshooting

“API key not set”

Make sure SAGE_API_KEY is exported or in your .env file.

Timeout errors

Increase SAGE_TIMEOUT_MS for slow models or complex prompts.

Connection refused

Check SAGE_LLM_URL is correct and the service is running.

Patterns

Common patterns for building LLM-powered agents.

Parallel Research

Spawn multiple researchers, combine results:

agent Researcher {
    topic: String

    on start {
        let result = try infer(
            "Research and provide 3 key facts about: {self.topic}"
        );
        emit(result);
    }

    on error(e) {
        emit("Research failed for topic");
    }
}

agent Synthesizer {
    findings: List<String>

    on start {
        let combined = join(self.findings, "\n\n");
        let synthesis = try infer(
            "Given these research findings:\n{combined}\n\n" ++
            "Provide a unified summary highlighting connections."
        );
        emit(synthesis);
    }

    on error(e) {
        emit("Synthesis failed");
    }
}

agent Coordinator {
    on start {
        // Parallel research
        let r1 = spawn Researcher { topic: "quantum computing" };
        let r2 = spawn Researcher { topic: "machine learning" };
        let r3 = spawn Researcher { topic: "cryptography" };

        let f1 = try await r1;
        let f2 = try await r2;
        let f3 = try await r3;

        // Synthesis
        let s = spawn Synthesizer {
            findings: [f1, f2, f3]
        };
        let result = try await s;

        print(result);
        emit(0);
    }

    on error(e) {
        print("Pipeline failed");
        emit(1);
    }
}

run Coordinator;

Chain of Thought

Break complex reasoning into steps:

agent ChainOfThought {
    question: String

    on start {
        let understand = try infer(
            "Question: {self.question}\n\n" ++
            "First, restate the question in your own words and identify what's being asked."
        );

        let analyze = try infer(
            "Question: {self.question}\n\n" ++
            "Understanding: {understand}\n\n" ++
            "Now, list the key concepts and relationships involved."
        );

        let solve = try infer(
            "Question: {self.question}\n\n" ++
            "Understanding: {understand}\n\n" ++
            "Analysis: {analyze}\n\n" ++
            "Now, provide a step-by-step solution."
        );

        let answer = try infer(
            "Question: {self.question}\n\n" ++
            "Solution: {solve}\n\n" ++
            "State the final answer concisely."
        );

        emit(answer);
    }

    on error(e) {
        emit("Reasoning failed: " ++ e);
    }
}

Validation Loop

Have agents check each other’s work:

agent Generator {
    task: String

    on start {
        let result = try infer(
            "Complete this task: {self.task}"
        );
        emit(result);
    }

    on error(e) {
        emit("Generation failed");
    }
}

agent Validator {
    task: String
    result: String

    on start {
        let valid = try infer(
            "Task: {self.task}\n\n" ++
            "Result: {self.result}\n\n" ++
            "Is this result correct and complete? " ++
            "Answer YES or NO, then explain briefly."
        );
        emit(valid);
    }

    on error(e) {
        emit("Validation failed");
    }
}

agent Main {
    on start {
        let task = "Write a haiku about programming";

        let gen = spawn Generator { task: task };
        let result = try await gen;

        let val = spawn Validator { task: task, result: result };
        let validation = try await val;

        print("Result: " ++ result);
        print("Validation: " ++ validation);
        emit(0);
    }

    on error(e) {
        emit(1);
    }
}

run Main;

Map-Reduce

Process items in parallel, combine results:

agent Processor {
    item: String

    on start {
        let result = try infer(
            "Process this item and extract key information: {self.item}"
        );
        emit(result);
    }

    on error(e) {
        emit("Processing failed");
    }
}

agent Reducer {
    items: List<String>

    on start {
        let combined = join(self.items, "\n---\n");
        let result = try infer(
            "Combine these processed items into a summary:\n{combined}"
        );
        emit(result);
    }

    on error(e) {
        emit("Reduction failed");
    }
}

agent MapReduce {
    on start {
        // Map phase - process in parallel
        let p1 = spawn Processor { item: "doc1 content" };
        let p2 = spawn Processor { item: "doc2 content" };
        let p3 = spawn Processor { item: "doc3 content" };

        let r1 = try await p1;
        let r2 = try await p2;
        let r3 = try await p3;

        // Reduce phase
        let reducer = spawn Reducer { items: [r1, r2, r3] };
        let final_result = try await reducer;

        print(final_result);
        emit(0);
    }

    on error(e) {
        emit(1);
    }
}

run MapReduce;

Debate

Multiple agents argue different positions:

agent Debater {
    position: String
    topic: String

    on start {
        let argument = try infer(
            "You are arguing {self.position} on the topic: {self.topic}\n\n" ++
            "Make your best argument in 2-3 sentences."
        );
        emit(argument);
    }

    on error(e) {
        emit("Argument unavailable");
    }
}

agent Judge {
    topic: String
    arg_for: String
    arg_against: String

    on start {
        let verdict = try infer(
            "Topic: {self.topic}\n\n" ++
            "Argument FOR:\n{self.arg_for}\n\n" ++
            "Argument AGAINST:\n{self.arg_against}\n\n" ++
            "Which argument is stronger and why? Be brief."
        );
        emit(verdict);
    }

    on error(e) {
        emit("Verdict unavailable");
    }
}

agent Main {
    on start {
        let topic = "AI will create more jobs than it destroys";

        let d1 = spawn Debater { position: "FOR", topic: topic };
        let d2 = spawn Debater { position: "AGAINST", topic: topic };

        let arg_for = try await d1;
        let arg_against = try await d2;

        let judge = spawn Judge {
            topic: topic,
            arg_for: arg_for,
            arg_against: arg_against
        };
        let verdict = try await judge;

        print("FOR: " ++ arg_for);
        print("AGAINST: " ++ arg_against);
        print("VERDICT: " ++ verdict);
        emit(0);
    }

    on error(e) {
        emit(1);
    }
}

run Main;

Built-in Tools

Sage provides built-in tools that agents can use to interact with external services. Tools are declared with use and their methods are called with the Tool.method() syntax.

Declaring Tools

To use a tool in an agent, declare it with use at the top of the agent body:

agent Fetcher {
    use Http

    on start {
        let response = try Http.get("https://httpbin.org/get");
        emit(response.status);
    }

    on error(e) {
        emit(-1);
    }
}

run Fetcher;

Available Tools

ToolDescription
HttpHTTP client for web requests

More tools are planned for future releases (Fs, Kv, Browser, etc.).

Error Handling

Tool calls are fallible operations. You must handle potential errors using try or catch:

// Propagate errors to the agent's on error handler
let response = try Http.get(url);

// Handle errors inline with a fallback
let response = catch Http.get(url) {
    HttpResponse { status: 0, body: "", headers: {} }
};

Environment Configuration

Tools can be configured via environment variables:

VariableDescriptionDefault
SAGE_HTTP_TIMEOUTHTTP request timeout in seconds30

HTTP Client

The Http tool provides methods for making HTTP requests.

Usage

Declare the tool with use Http in your agent:

agent ApiClient {
    use Http

    on start {
        let response = try Http.get("https://api.example.com/data");
        print("Status: " ++ str(response.status));
        print("Body: " ++ response.body);
        emit(response.status);
    }

    on error(e) {
        print("Request failed");
        emit(-1);
    }
}

run ApiClient;

Methods

Http.get(url: String) -> HttpResponse

Performs an HTTP GET request.

let response = try Http.get("https://httpbin.org/get");

Http.post(url: String, body: String) -> HttpResponse

Performs an HTTP POST request with a JSON body.

let response = try Http.post(
    "https://httpbin.org/post",
    "{\"key\": \"value\"}"
);

HttpResponse

Both methods return an HttpResponse with the following fields:

FieldTypeDescription
statusIntHTTP status code (e.g., 200, 404, 500)
bodyStringResponse body as text
headersMap<String, String>Response headers

Examples

Fetching JSON Data

agent JsonFetcher {
    use Http
    url: String

    on start {
        let response = try Http.get(self.url);
        if response.status == 200 {
            emit(response.body);
        } else {
            emit("Error: " ++ str(response.status));
        }
    }

    on error(e) {
        emit("Request failed");
    }
}

run JsonFetcher { url: "https://httpbin.org/json" };

Posting Data

agent DataPoster {
    use Http

    on start {
        let payload = "{\"message\": \"Hello from Sage!\"}";
        let response = try Http.post("https://httpbin.org/post", payload);
        emit(response.status);
    }

    on error(e) {
        emit(-1);
    }
}

run DataPoster;

Error Recovery

agent ResilientFetcher {
    use Http
    urls: List<String>

    on start {
        for url in self.urls {
            let response = catch Http.get(url) {
                HttpResponse { status: 0, body: "", headers: {} }
            };
            if response.status == 200 {
                emit(response.body);
                return;
            }
        }
        emit("All URLs failed");
    }
}

run ResilientFetcher {
    urls: ["https://primary.example.com", "https://backup.example.com"]
};

Configuration

VariableDescriptionDefault
SAGE_HTTP_TIMEOUTRequest timeout in seconds30

The HTTP client automatically sets a User-Agent header of sage-agent/{version}.

Testing Overview

Sage has a built-in testing framework that makes it easy to test your agents and functions. Tests are first-class citizens in the language, not bolted-on annotations.

Why Built-In Testing?

Agent-based systems are notoriously hard to test:

  • LLM calls are non-deterministic
  • Agent lifecycles involve async operations
  • Message passing creates complex interaction patterns

Sage’s testing framework solves these problems with:

  • First-class LLM mocking — deterministic tests without network calls
  • Async-aware test bodiesspawn and await work naturally in tests
  • Concurrent execution — tests run in parallel by default for speed

Quick Start

Create a test file ending in _test.sg:

src/math_test.sg:

test "addition works" {
    assert_eq(1 + 1, 2);
}

test "multiplication works" {
    let result = 6 * 7;
    assert_eq(result, 42);
}

Run your tests:

sage test .

Output:

🦉 Ward Running 2 tests from 1 file

  PASS math_test.sg::addition works
  PASS math_test.sg::multiplication works

🦉 Ward test result: ok. 2 passed, 0 failed, 0 skipped [0.82s]

Test File Convention

Test files must end in _test.sg. The test runner automatically discovers all test files in your project:

my_project/
├── sage.toml
└── src/
    ├── main.sg
    ├── utils.sg
    ├── utils_test.sg    # Tests for utils.sg
    └── agents_test.sg   # Tests for agents

Next Steps

Writing Tests

Test Syntax

Tests are declared with the test keyword followed by a description string and a block:

test "descriptive name for the test" {
    // test body
}

The description appears in test output, so make it meaningful:

  • "user can log in with valid credentials"
  • "empty list returns None for find"
  • "test1" (not descriptive)

Serial Tests

By default, tests run concurrently for speed. Use @serial when a test needs isolation:

@serial test "modifies global state" {
    // This test runs alone, not concurrently with others
}

Use @serial when:

  • Tests modify shared state
  • Tests depend on specific timing
  • Tests use resources that can’t be shared

Testing Functions

Test regular functions by calling them and asserting on results:

fn factorial(n: Int) -> Int {
    if n <= 1 {
        return 1;
    }
    return n * factorial(n - 1);
}

test "factorial of 5 is 120" {
    assert_eq(factorial(5), 120);
}

test "factorial of 0 is 1" {
    assert_eq(factorial(0), 1);
}

test "factorial of 1 is 1" {
    assert_eq(factorial(1), 1);
}

Testing Agents

Test agents by spawning them with mocked LLM responses:

agent Summariser {
    topic: String

    on start {
        let summary = try infer("Summarise: {self.topic}");
        emit(summary);
    }

    on error(e) {
        emit("Error occurred");
    }
}

test "summariser returns LLM response" {
    mock infer -> "This is a summary of quantum physics.";

    let result = await spawn Summariser { topic: "quantum physics" };
    assert_eq(result, "This is a summary of quantum physics.");
}

Test Body Semantics

Test bodies are async by default — you can use await and spawn without special syntax:

test "two agents can run concurrently" {
    mock infer -> "Result A";
    mock infer -> "Result B";

    let a = spawn Researcher { topic: "A" };
    let b = spawn Researcher { topic: "B" };

    let result_a = await a;
    let result_b = await b;

    assert_eq(result_a, "Result A");
    assert_eq(result_b, "Result B");
}

Organising Tests

Keep tests close to the code they test:

src/
├── auth.sg
├── auth_test.sg      # Tests for auth.sg
├── payments.sg
└── payments_test.sg  # Tests for payments.sg

Or use a dedicated test directory:

src/
├── main.sg
└── lib/
    ├── utils.sg
    └── utils_test.sg

Assertions

Sage provides a rich set of assertion functions for testing. All assertions are only available in test files (*_test.sg).

Basic Assertions

assert

Assert that an expression is true:

test "basic assertion" {
    assert(1 + 1 == 2);
    assert(true);
}

assert_eq / assert_neq

Assert equality or inequality:

test "equality assertions" {
    assert_eq(1 + 1, 2);
    assert_neq(1 + 1, 3);

    assert_eq("hello", "hello");
    assert_neq("hello", "world");
}

assert_true / assert_false

Assert boolean values:

test "boolean assertions" {
    assert_true(5 > 3);
    assert_false(5 < 3);
}

Comparison Assertions

assert_gt / assert_lt

Assert greater than or less than:

test "comparison assertions" {
    assert_gt(10, 5);   // 10 > 5
    assert_lt(5, 10);   // 5 < 10
}

assert_gte / assert_lte

Assert greater than or equal / less than or equal:

test "inclusive comparison" {
    assert_gte(10, 10);  // 10 >= 10
    assert_gte(10, 5);   // 10 >= 5
    assert_lte(5, 5);    // 5 <= 5
    assert_lte(5, 10);   // 5 <= 10
}

String Assertions

assert_contains / assert_not_contains

Assert string containment:

test "string containment" {
    assert_contains("hello world", "world");
    assert_not_contains("hello world", "foo");
}

assert_starts_with / assert_ends_with

Assert string prefix or suffix:

test "string prefix and suffix" {
    assert_starts_with("hello world", "hello");
    assert_ends_with("hello world", "world");
}

Collection Assertions

assert_empty / assert_not_empty

Assert collection emptiness:

test "collection emptiness" {
    assert_empty([]);
    assert_not_empty([1, 2, 3]);

    assert_empty("");
    assert_not_empty("hello");
}

assert_len

Assert collection length:

test "collection length" {
    assert_len([1, 2, 3], 3);
    assert_len("hello", 5);
}

Error Assertions

assert_fails

Assert that an expression produces an error:

test "agent handles error correctly" {
    mock infer -> fail("simulated failure");

    let handle = spawn Summariser { topic: "test" };
    assert_fails(await handle);
}

This is useful for testing error handling paths in your agents.

Assertion Failures

When an assertion fails, the test stops immediately and reports the failure:

  FAIL math_test.sg::addition works

Failures:

  math_test.sg::addition works
    thread 'addition_works' panicked at src/main.rs:7:5:
    assertion failed: 1 + 1 == 3

The error message shows:

  • Which test failed
  • Where in the generated code the failure occurred
  • The assertion that failed

Mocking LLM Calls

The most powerful feature of Sage’s testing framework is first-class LLM mocking. In test files, you can specify exactly what infer calls should return, making your tests deterministic and fast.

Basic Mocking

Use mock infer -> value; to specify what the next infer call should return:

test "infer returns mocked value" {
    mock infer -> "This is a mocked response";

    let result: String = try infer("Summarise something");
    assert_eq(result, "This is a mocked response");
}

The mock is consumed by the infer call — each mock is used exactly once.

Multiple Mocks

When your test makes multiple infer calls, queue up multiple mocks in order:

test "multiple infer calls" {
    mock infer -> "First response";
    mock infer -> "Second response";
    mock infer -> "Third response";

    let r1 = try infer("Query 1");
    let r2 = try infer("Query 2");
    let r3 = try infer("Query 3");

    assert_eq(r1, "First response");
    assert_eq(r2, "Second response");
    assert_eq(r3, "Third response");
}

Mocks are consumed in FIFO order (first in, first out).

Mocking Structured Output

For typed infer calls, mock with the appropriate record structure:

record Summary {
    text: String,
    confidence: Float,
}

test "structured infer returns typed mock" {
    mock infer -> Summary {
        text: "Quantum computing is fast.",
        confidence: 0.88
    };

    let summary: Summary = try infer("Summarise quantum computing");
    assert_eq(summary.text, "Quantum computing is fast.");
    assert_gt(summary.confidence, 0.8);
}

Mocking Failures

Use fail("message") to mock an infer failure:

test "agent handles infer failure" {
    mock infer -> fail("rate limit exceeded");

    let handle = spawn ResilientResearcher { topic: "test" };
    let result = await handle;

    // Agent's fallback behaviour
    assert_eq(result, "unavailable");
}

This is essential for testing error handling paths.

Testing Agents with Mocks

When testing agents that use infer, mocks are consumed by the agent’s infer calls:

agent Researcher {
    topic: String

    on start {
        let summary = try infer("Research: {self.topic}");
        emit(summary);
    }

    on error(e) {
        emit("Research failed");
    }
}

test "researcher emits summary" {
    mock infer -> "Quantum computing uses qubits.";

    let result = await spawn Researcher { topic: "quantum" };
    assert_eq(result, "Quantum computing uses qubits.");
}

Testing Multi-Agent Systems

For agents that spawn other agents, each agent’s infer calls consume mocks in execution order:

test "coordinator gets results from two researchers" {
    mock infer -> "Summary about AI";
    mock infer -> "Summary about robots";

    let c = spawn Coordinator {
        topics: ["AI", "robots"]
    };
    let results = await c;

    assert_contains(results, "AI");
    assert_contains(results, "robots");
}

Mock Queue Exhaustion

If an infer call is made without an available mock, the test fails with error code E054:

Error: infer called with no mock available (E054)

Always provide enough mocks for all infer calls in your test.

Best Practices

  1. One assertion per test — easier to identify failures
  2. Descriptive mock values — make it clear what’s being tested
  3. Test error paths — use fail() to test error handling
  4. Keep mocks simple — avoid complex JSON in mocks when possible

Editor Support

Sage includes first-class editor support with syntax highlighting and real-time diagnostics via the Language Server Protocol (LSP).

Supported Editors

EditorExtensionHighlightingDiagnostics
ZedBuilt-inTree-sitterLSP
VS CodeMarketplaceTextMateLSP

Features

All Sage editor extensions provide:

  • Syntax Highlighting — Keywords, strings, comments, types, and more
  • Real-time Diagnostics — Errors and warnings as you type
  • Auto-indentation — Smart indentation for blocks and expressions

Language Server

The Sage language server (sage-sense) provides:

  • Parse error reporting
  • Type checking errors
  • Undefined variable detection
  • All compiler diagnostics in real-time

The language server is built into the sage CLI and starts automatically when you open a .sg file in a supported editor.

Manual LSP Setup

If you’re using an editor that supports LSP but doesn’t have a Sage extension, you can configure it to use:

sage sense

This starts the language server on stdin/stdout using the standard LSP protocol.

Zed

Zed is a high-performance code editor with native Sage support.

Installation

  1. Open Zed
  2. Press Cmd+Shift+X to open Extensions
  3. Search for “Sage”
  4. Click Install

Alternatively, use the command palette (Cmd+Shift+P) and run “zed: install extension”.

Features

The Sage extension for Zed provides:

  • Tree-sitter Highlighting — Fast, accurate syntax highlighting
  • LSP Diagnostics — Real-time error reporting from the Sage compiler
  • Auto-indentation — Smart indentation for agents, functions, and blocks

Requirements

The language server requires sage to be on your PATH. Install via:

# Homebrew (macOS)
brew install sagelang/sage/sage

# Cargo
cargo install sage-lang

# Quick install
curl -fsSL https://raw.githubusercontent.com/sagelang/sage/main/scripts/install.sh | bash

Troubleshooting

No syntax highlighting

If syntax highlighting isn’t working:

  1. Ensure the file has a .sg extension
  2. Check that the Sage extension is installed (Extensions → Installed)
  3. Try restarting Zed

No diagnostics

If you’re not seeing error diagnostics:

  1. Verify sage is on your PATH: which sage
  2. Check Zed logs: Cmd+Shift+P → “zed: open log”
  3. Look for “sage-sense” or “language server” errors

Extension not loading

If the extension fails to load:

  1. Uninstall the extension
  2. Restart Zed
  3. Reinstall the extension

VS Code

Visual Studio Code is supported via the Sage extension.

Installation

  1. Open VS Code
  2. Press Cmd+Shift+X (Mac) or Ctrl+Shift+X (Windows/Linux) to open Extensions
  3. Search for “Sage”
  4. Click Install

Features

The Sage extension for VS Code provides:

  • TextMate Highlighting — Syntax highlighting for all Sage constructs
  • LSP Diagnostics — Real-time error reporting from the Sage compiler
  • File Icons — Custom icon for .sg files

Requirements

The language server requires sage to be on your PATH. Install via:

# Homebrew (macOS)
brew install sagelang/sage/sage

# Cargo
cargo install sage-lang

# Quick install
curl -fsSL https://raw.githubusercontent.com/sagelang/sage/main/scripts/install.sh | bash

Configuration

The extension can be configured in VS Code settings:

{
  "sage.path": "/usr/local/bin/sage"
}
SettingDescriptionDefault
sage.pathPath to the sage binaryAuto-detected from PATH

Troubleshooting

No syntax highlighting

If syntax highlighting isn’t working:

  1. Ensure the file has a .sg extension
  2. Check that the Sage extension is installed
  3. Try reloading the window: Cmd+Shift+P → “Developer: Reload Window”

No diagnostics

If you’re not seeing error diagnostics:

  1. Verify sage is on your PATH: which sage
  2. Check the Output panel: View → Output → select “Sage Language Server”
  3. Look for connection or startup errors

Extension not activating

If the extension isn’t activating:

  1. Check the Extensions panel for errors
  2. Disable and re-enable the extension
  3. Check VS Code’s developer console for errors

CLI Commands

The sage command-line tool compiles and runs Sage programs.

sage new

Create a new Sage project with scaffolding:

sage new my_project

This creates:

my_project/
├── sage.toml           # Project manifest
└── src/
    └── main.sg         # Entry point with example code

Examples

# Create a new project
sage new my_agent

# Enter the project and run it
cd my_agent
sage run .

sage run

Compile and execute a Sage program:

sage run program.sg

Options

OptionDescription
--releaseBuild with optimizations
-q, --quietMinimal output

Examples

# Run a program
sage run hello.sg

# Run with optimizations
sage run hello.sg --release

# Run quietly (only program output)
sage run hello.sg -q

sage build

Compile a Sage program to a native binary without running it:

sage build program.sg

Options

OptionDescription
--releaseBuild with optimizations
-o, --output <dir>Output directory (default: target/sage)
--emit-rustOnly generate Rust code, don’t compile

Examples

# Build a binary
sage build hello.sg

# Build with optimizations
sage build hello.sg --release

# Custom output directory
sage build hello.sg -o ./out

# Generate Rust code only (for inspection)
sage build hello.sg --emit-rust

Output Structure

After building, you’ll find:

target/sage/
  hello/
    main.rs      # Generated Rust code
    hello        # Native binary (if not --emit-rust)

sage check

Type-check a Sage program without compiling or running:

sage check program.sg

This is useful for quick validation during development.

Examples

# Check for errors
sage check hello.sg

# Output on success:
# ✨ No errors in hello.sg

sage test

Run tests in a Sage project:

sage test .

This discovers all *_test.sg files, compiles them, and runs the tests.

Options

OptionDescription
--filter <pattern>Only run tests matching the pattern
--file <path>Run only tests in the specified file
--serialRun all tests sequentially (not in parallel)
-v, --verboseShow detailed failure output
--no-colourDisable colored output

Examples

# Run all tests in the project
sage test .

# Run tests matching "auth"
sage test . --filter auth

# Run tests in a specific file
sage test . --file src/utils_test.sg

# Run tests sequentially (useful for debugging)
sage test . --serial

# Verbose output with failure details
sage test . --verbose

Output

🦉 Ward Running 3 tests from 2 files

  PASS auth_test.sg::login succeeds with valid credentials
  PASS auth_test.sg::login fails with invalid password
  FAIL utils_test.sg::parse handles empty input

🦉 Ward test result: FAILED. 2 passed, 1 failed, 0 skipped [1.23s]

Exit Codes

CodeMeaning
0All tests passed
1One or more tests failed

sage sense

Start the Language Server Protocol (LSP) server for editor integration:

sage sense

This command starts the Sage language server on stdin/stdout. It’s typically invoked automatically by editor extensions (Zed, VS Code) rather than manually.

Features

The language server provides:

  • Real-time parse error reporting
  • Type checking diagnostics
  • Undefined variable detection
  • All compiler error codes

Manual Usage

For editors without a Sage extension, configure the LSP client to run sage sense as the language server command.

Example for generic LSP configuration:

{
  "languageId": "sage",
  "command": "sage",
  "args": ["sense"],
  "fileExtensions": [".sg"]
}

Global Options

OptionDescription
-h, --helpShow help information
-V, --versionShow version

Exit Codes

CodeMeaning
0Success
1Compilation error (parse, type, or codegen)
OtherProgram exit code (when using sage run)

Compilation Modes

Sage automatically selects the fastest compilation mode:

Pre-compiled Toolchain (Default)

When installed via the install script or release binaries, Sage includes a pre-compiled Rust toolchain. This provides fast compilation without requiring Rust to be installed.

Cargo Fallback

If no pre-compiled toolchain is found, Sage falls back to using cargo. This requires Rust to be installed but allows compilation on any platform.

The output will indicate which mode was used:

✨ Done Compiled hello.sg in 0.42s           # Pre-compiled toolchain
✨ Done Compiled hello.sg (cargo) in 2.31s   # Cargo fallback

Environment Variables

Sage uses environment variables to configure LLM integration and the compiler.

LLM Configuration

These variables configure the infer expression.

SAGE_API_KEY

Required for LLM features. Your API key for the LLM provider.

export SAGE_API_KEY="sk-..."

SAGE_LLM_URL

Base URL for the LLM API. Defaults to OpenAI.

# OpenAI (default)
export SAGE_LLM_URL="https://api.openai.com/v1"

# Ollama (local)
export SAGE_LLM_URL="http://localhost:11434/v1"

# Azure OpenAI
export SAGE_LLM_URL="https://your-resource.openai.azure.com/openai/deployments/your-deployment"

# Other OpenAI-compatible providers
export SAGE_LLM_URL="https://api.together.xyz/v1"

SAGE_MODEL

Which model to use. Default: gpt-4o-mini

export SAGE_MODEL="gpt-4o"

SAGE_MAX_TOKENS

Maximum tokens per response. Default: 1024

export SAGE_MAX_TOKENS="2048"

SAGE_TIMEOUT_MS

Request timeout in milliseconds. Default: 30000 (30 seconds)

export SAGE_TIMEOUT_MS="60000"

SAGE_INFER_RETRIES

Maximum retries for structured output parsing. When infer returns a type other than String, the runtime parses the LLM’s response as JSON. If parsing fails, it retries with error feedback. Default: 3

export SAGE_INFER_RETRIES="5"

Compiler Configuration

SAGE_TOOLCHAIN

Override the path to the pre-compiled toolchain. Normally this is detected automatically.

export SAGE_TOOLCHAIN="/path/to/toolchain"

The toolchain directory should contain:

  • bin/rustc - The Rust compiler
  • libs/ - Pre-compiled runtime libraries

Using .env Files

Sage automatically loads .env files from the current directory:

# .env
SAGE_API_KEY=sk-...
SAGE_MODEL=gpt-4o
SAGE_MAX_TOKENS=2048

This is useful for per-project configuration and keeping secrets out of your shell history.

Provider Quick Reference

OpenAI

export SAGE_API_KEY="sk-..."
export SAGE_MODEL="gpt-4o"

Ollama (Local)

export SAGE_LLM_URL="http://localhost:11434/v1"
export SAGE_MODEL="llama2"
# No API key needed

Azure OpenAI

export SAGE_LLM_URL="https://your-resource.openai.azure.com/openai/deployments/your-deployment"
export SAGE_API_KEY="your-azure-key"
export SAGE_MODEL="gpt-4"

Together AI

export SAGE_LLM_URL="https://api.together.xyz/v1"
export SAGE_API_KEY="your-key"
export SAGE_MODEL="meta-llama/Llama-3-70b-chat-hf"

Error Messages

Sage provides helpful error messages with source locations and suggestions.

Parse Errors

Unexpected token

error: unexpected token
  --> hello.sg:5:10
  |
5 |     let x =
  |          ^ expected expression

Fix: Complete the expression or remove the incomplete statement.

Missing semicolon

error: expected ';'
  --> hello.sg:3:15
  |
3 |     let x = 42
  |               ^ expected ';' after statement

Fix: Add a semicolon at the end of the statement.

Unclosed brace

error: unclosed '{'
  --> hello.sg:2:12
  |
2 |     on start {
  |              ^ this '{' was never closed

Fix: Add the matching closing brace }.

Type Errors

Type mismatch

error: type mismatch
  --> hello.sg:7:20
  |
7 |     let x: Int = "hello";
  |                  ^^^^^^^ expected Int, found String

Fix: Use a value of the correct type or change the type annotation.

Undefined variable

error: undefined variable 'foo'
  --> hello.sg:5:10
  |
5 |     print(foo);
  |           ^^^ not found in this scope

Fix: Define the variable before using it, or check for typos.

Unknown agent

error: unknown agent 'Worker'
  --> hello.sg:10:22
  |
10 |     let w = spawn Worker {};
   |                   ^^^^^^ agent not defined

Fix: Define the agent or check the spelling.

Missing field

error: missing field 'name'
  --> hello.sg:15:22
  |
15 |     let g = spawn Greeter {};
   |                   ^^^^^^^^^ field 'name' not provided

Fix: Provide all required fields when spawning:

let g = spawn Greeter { name: "World" };

Unhandled fallible operation (E013)

error[E013]: fallible operation must be handled
  --> hello.sg:5:15
  |
5 |     let x = infer("prompt");
  |             ^^^^^^^^^^^^^^^ this can fail
  |
  = help: use 'try' to propagate or 'catch' to handle inline

Fix: Handle the error with try or catch:

// Propagate to on error handler
let x = try infer("prompt");

// Or handle inline
let x = catch infer("prompt") {
    "fallback"
};

Wrong message type

error: type mismatch in send
  --> hello.sg:8:10
  |
8 |     try send(worker, "hello");
  |              ^^^^^^^^^^^^^^^^ worker expects WorkerMsg, got String

Fix: Send a value of the type the agent accepts (defined by its receives clause).

Runtime Errors

API key not set

error: SAGE_API_KEY environment variable not set

Fix: Set your API key:

export SAGE_API_KEY="sk-..."

LLM timeout

error: LLM request timed out after 30000ms

Fix: Increase the timeout or use a faster model:

export SAGE_TIMEOUT_MS="60000"

Connection refused

error: failed to connect to LLM API

Fix: Check that SAGE_LLM_URL is correct and the service is running.

Compilation Errors

Rust not found (cargo mode)

error: Failed to run cargo build. Is Rust installed?

This happens when using the cargo fallback without Rust installed.

Fix: Either:

  • Install Sage using the install script (includes pre-compiled toolchain)
  • Install Rust from https://rustup.rs

Linker not found

error: linker 'cc' not found

Fix: Install a C compiler:

# Ubuntu/Debian
sudo apt install gcc

# macOS
xcode-select --install

Getting Help

If you encounter an error not listed here:

  1. Check the GitHub issues
  2. Open a new issue with:
    • The error message
    • Your Sage code (minimal example)
    • Your environment (OS, Sage version)