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:
- Getting Started — Install Sage and write your first program
- Language Guide — Syntax, types, and control flow
- Agents — State, handlers, spawning, and messaging
- LLM Integration — Using
inferto call language models - Tools — Built-in tools like HTTP for external services
- Testing — Write tests with first-class LLM mocking
- 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:
-
agent Main { ... }— Declares an agent namedMain. Agents are the basic unit of computation in Sage. -
on start { ... }— Thestarthandler runs when the agent is spawned. Every agent needs at least one handler. -
print("Hello from Sage!")— Prints a message to the console. -
emit(0)— Emits a value, signaling that the agent has finished. The emitted value becomes the agent’s result. -
run Main— Tells the compiler which agent to start. Every Sage program needs exactly onerunstatement.
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?
-
topic: String— TheResearcheragent has a field calledtopic. Fields are the agent’s state, initialized when spawned. -
try infer("...")— Calls the LLM with the given prompt. The{self.topic}syntax interpolates the agent’s field into the prompt. Thetrypropagates errors toon error. -
on error(e)— Handles errors fromtryexpressions. Without this, the agent would panic on failure. -
spawn Researcher { topic: "..." }— Creates a newResearcheragent with the given field value. -
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,whileblocks
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
| Type | Description | Example |
|---|---|---|
Int | 64-bit signed integer | 42, -17 |
Float | 64-bit floating point | 3.14, -0.5 |
Bool | Boolean | true, false |
String | UTF-8 string | "hello" |
Unit | No 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:
| Function | Signature | Description |
|---|---|---|
print | (String) -> Unit | Print to console |
str | (T) -> String | Convert any value to string |
len | (List<T>) -> Int | Get list or map length |
push | (List<T>, T) -> List<T> | Append to list |
join | (List<String>, String) -> String | Join strings |
int_to_str | (Int) -> String | Convert int to string |
str_contains | (String, String) -> Bool | Check substring |
sleep_ms | (Int) -> Unit | Sleep for milliseconds |
map_get | (Map<K,V>, K) -> Option<V> | Get value from map |
map_set | (Map<K,V>, K, V) -> Unit | Set key-value in map |
map_has | (Map<K,V>, K) -> Bool | Check if key exists |
map_delete | (Map<K,V>, K) -> Unit | Remove 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
- Spawn — Agent is created with initial state
- Start — The
on starthandler runs - Running — Agent can receive messages, spawn other agents
- Emit — Agent produces its result
- 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
- State — Agent fields
- Event Handlers — Responding to events
- Spawning & Awaiting — Creating and coordinating agents
- Messaging — Communication between agents
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
on startruns first, exactly onceon errorruns if atryexpression fails- 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
await | send / receive | |
|---|---|---|
| Direction | Get final result from agent | Ongoing communication |
| Blocking | Yes, waits for agent to complete | send returns immediately, receive blocks until message arrives |
| Use case | One-shot tasks | Long-running workers, event loops |
Mailbox Semantics
- Each agent has a bounded mailbox (128 messages by default)
- When the mailbox is full,
sendblocks 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_timeoutin 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:
- Injects the expected schema into the prompt
- Parses the LLM’s response as JSON
- 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
| Tool | Description |
|---|---|
| Http | HTTP 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:
| Variable | Description | Default |
|---|---|---|
SAGE_HTTP_TIMEOUT | HTTP request timeout in seconds | 30 |
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:
| Field | Type | Description |
|---|---|---|
status | Int | HTTP status code (e.g., 200, 404, 500) |
body | String | Response body as text |
headers | Map<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
| Variable | Description | Default |
|---|---|---|
SAGE_HTTP_TIMEOUT | Request timeout in seconds | 30 |
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 bodies —
spawnandawaitwork 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 and best practices
- Assertions — available assertion functions
- Mocking LLMs — how to mock
infercalls
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
- One assertion per test — easier to identify failures
- Descriptive mock values — make it clear what’s being tested
- Test error paths — use
fail()to test error handling - 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
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
- Open Zed
- Press
Cmd+Shift+Xto open Extensions - Search for “Sage”
- 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:
- Ensure the file has a
.sgextension - Check that the Sage extension is installed (Extensions → Installed)
- Try restarting Zed
No diagnostics
If you’re not seeing error diagnostics:
- Verify
sageis on your PATH:which sage - Check Zed logs:
Cmd+Shift+P→ “zed: open log” - Look for “sage-sense” or “language server” errors
Extension not loading
If the extension fails to load:
- Uninstall the extension
- Restart Zed
- Reinstall the extension
VS Code
Visual Studio Code is supported via the Sage extension.
Installation
- Open VS Code
- Press
Cmd+Shift+X(Mac) orCtrl+Shift+X(Windows/Linux) to open Extensions - Search for “Sage”
- 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
.sgfiles
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"
}
| Setting | Description | Default |
|---|---|---|
sage.path | Path to the sage binary | Auto-detected from PATH |
Troubleshooting
No syntax highlighting
If syntax highlighting isn’t working:
- Ensure the file has a
.sgextension - Check that the Sage extension is installed
- Try reloading the window:
Cmd+Shift+P→ “Developer: Reload Window”
No diagnostics
If you’re not seeing error diagnostics:
- Verify
sageis on your PATH:which sage - Check the Output panel: View → Output → select “Sage Language Server”
- Look for connection or startup errors
Extension not activating
If the extension isn’t activating:
- Check the Extensions panel for errors
- Disable and re-enable the extension
- 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
| Option | Description |
|---|---|
--release | Build with optimizations |
-q, --quiet | Minimal 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
| Option | Description |
|---|---|
--release | Build with optimizations |
-o, --output <dir> | Output directory (default: target/sage) |
--emit-rust | Only 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
| Option | Description |
|---|---|
--filter <pattern> | Only run tests matching the pattern |
--file <path> | Run only tests in the specified file |
--serial | Run all tests sequentially (not in parallel) |
-v, --verbose | Show detailed failure output |
--no-colour | Disable 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
| Code | Meaning |
|---|---|
| 0 | All tests passed |
| 1 | One 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
| Option | Description |
|---|---|
-h, --help | Show help information |
-V, --version | Show version |
Exit Codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Compilation error (parse, type, or codegen) |
| Other | Program 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 compilerlibs/- 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:
- Check the GitHub issues
- Open a new issue with:
- The error message
- Your Sage code (minimal example)
- Your environment (OS, Sage version)