AgentLang
A tiny, self-contained DSL for agentic workflows.
Define agents, typed tasks, declarative workflows, and explicit pipelines — then run them with deterministic mock adapters or live LLM backends (OpenAI or Anthropic/Claude). No framework. No magic. Everything compiles from source to execution in the native Rust implementation.
tool web_search(query: String) -> List[Obj{title: String, url: String, snippet: String}] {}
agent planner {
model: "gpt-4.1"
, tools: [web_search]
}
agent reviewer {
model: "gpt-4.1-mini"
, tools: [web_search]
}
task plan_blog(topic: String) -> Obj{outline: String, sources: List[String]} by agent {}
task review_outline(topic: String, outline: String, sources: List[String]) -> Obj{approved: Bool, feedback: String} by agent {}
task revise_outline(topic: String, outline: String, sources: List[String], feedback: String) -> Obj{outline: String, sources: List[String]} by agent {}
workflow publish_topic_blog(topic: String) -> String {
stage plan = planner does plan_blog(topic);
review outline = reviewer checks plan revise with planner using revise_outline max_rounds 2;
return outline.outline;
}
$ cargo run -- examples/blog.agent blog_post \
--input '{"topic":"agent memory patterns"}'
{
"result": "[writer] Draft article:\n[planner] key points for 'agent memory patterns'"
}
Why AgentLang?
Most "agent frameworks" hide the execution model behind layers of abstraction. AgentLang does the opposite — the language has a formal grammar, a static type checker, and a runtime you can read in an afternoon.
| AgentLang | |
|---|---|
| Runtime dependencies | Rust library dependencies; no service required in mock mode |
| Type checking | Static, structural |
| High-level authoring | workflow, stage, review |
| Parallel execution | Built-in parallel { } join, parallel map, and race |
| Looping | while, break, continue |
| Retry / fallback | First-class syntax with typed retry safety |
| LLM backend | Optional (--adapter openai/live or --adapter anthropic) |
Where to start
-
New to AgentLang?
Follow the tutorial to run your first pipeline or workflow in under five minutes.
-
Learn the language
Understand agents, tasks, workflows, pipelines, the type system, and parallel execution.
-
Connect to OpenAI or Anthropic
Switch from deterministic mock mode to live LLM adapters.
-
Read the full reference
Complete syntax, CLI flags, and runtime semantics.
Quick Start
This guide gets you from zero to a running AgentLang pipeline or workflow in five minutes.
Prerequisites
- Rust 1.94.1+ (the repository pins the supported toolchain)
- This repository cloned locally
Verify your Rust toolchain:
rustc --version
# rustc 1.94.1 or newer
1. Verify the install
Build and test the native implementation to confirm everything is wired up:
cargo test --locked
No output means success.
2. Run your first pipeline
The blog.agent example defines a simple two-step pipeline: research a topic, then draft an article.
cargo run -- examples/blog.agent blog_post \
--input '{"topic":"agent memory patterns"}'
Output:
{
"result": "[writer] Draft article:\n[planner] key points for 'agent memory patterns'"
}
Note
Mock mode
By default, pipelines run in mock mode — all task handlers are deterministic local functions that return structured placeholders. No API key required.
3. Run your first workflow
The higher-level workflow surface is the recommended authoring model for multi-agent handoffs and review loops.
cargo run -- examples/multiagent_blog.agent publish_topic_blog \
--input '{"topic":"agent memory systems"}'
Lower it to explicit pipeline IR:
cargo run -- examples/multiagent_blog.agent publish_topic_blog --lower
This shows the generated run, while, and break structure that the runtime actually executes.
4. Try parallel execution
The compare.agent pipeline runs two research tasks in parallel, then merges results:
cargo run -- examples/compare.agent compare_options \
--input '{"query":"vector database"}'
Output:
{
"result": "[reviewer] Option A vs B\nA: [planner] key points for 'vector database option A'\nB: [planner] key points for 'vector database option B'"
}
The two research calls ran concurrently — you'll see both results merged before the compare step executes.
5. See retry and fallback
The reliability.agent pipeline uses retries and on_fail use to handle transient failures gracefully.
Run with a low failure count (succeeds before fallback kicks in):
cargo run -- examples/reliability.agent resilient_brief \
--input '{"topic":"api-status","fail_count":1}'
{
"result": "[ops] Draft article:\nFresh path: [ops] fetched payload for api-status"
}
Force the fallback by exceeding the retry budget:
cargo run -- examples/reliability.agent resilient_brief \
--input '{"topic":"api-status","fail_count":5}'
{
"result": "[ops] Draft article:\nFallback path: fallback for api-status"
}
Tip
What just happened?
fail_count: 5exceeds theretries 2budget in the pipeline, so theon_fail useclause provides a fallback value. Theif/elseblock then routes execution based on whether the fallback was used.
6. Start the REPL
For interactive exploration:
cargo run -- repl --adapter mock
AGL REPL (adapter=mock). Type 'help' for commands, 'exit' to quit.
> load examples/blog.agent
Loaded 'examples/blog.agent': 2 agents, 2 tasks, 1 pipelines.
> run blog_post {"topic":"agent memory"}
{
"result": "[writer] Draft article:\n[planner] key points for 'agent memory'"
}
> exit
The REPL is stateful: use load <path>, then run <pipeline-or-workflow> [json]. lower, list, clear, and help are also available. Errors are printed and the session continues.
7. Trace a live run
When debugging live agent behavior, enable tracing. This works with both --adapter live (OpenAI) and --adapter anthropic (Claude):
cargo run -- examples/incident_runbook.agent respond_to_incident \
--adapter live \
--trace-live \
--input '{"incident":"database failover drill"}'
Or with Anthropic/Claude:
cargo run -- examples/incident_runbook.agent respond_to_incident \
--adapter anthropic \
--trace-live \
--input '{"incident":"database failover drill"}'
Trace lines are printed to stderr and show:
- agent task start/end
- LLM request mode (OpenAI or Anthropic)
- tool calls and tool results
- final structured task outputs
Next steps
- Understand the language → Agents, Tasks, Workflows, Pipelines
- Write your own pipeline → Your First Pipeline
- Connect to OpenAI or Anthropic → Adapters
Your First Pipeline
This tutorial walks through building an AgentLang pipeline from scratch — a support triage workflow that classifies a message, routes it to the right queue, and generates a response.
By the end you'll have a working .agent file and understand the relationship between agents, tasks, and pipelines.
Note
Pipeline vs workflow
pipelineis the explicit low-level execution language. For most multi-agent authoring, preferworkflowand let AgentLang lower it to a pipeline automatically. This tutorial stays withpipelinebecause it teaches the core execution model directly.
The goal
Given a user message like "urgent refund request", we want to:
- Extract the intent and urgency
- Route to the correct support queue
- Generate a reply
This mirrors the included examples/support.agent — but we'll build it step by step.
Step 1: Declare an agent
Create a new file my_support.agent:
agent triage {
model: "gpt-4.1-mini"
, tools: []
}
An agent block binds a name to a model and tool list. You reference it later with by triage in run statements.
Step 2: Declare tasks
Tasks are typed signatures — they declare what goes in and what comes out:
agent triage {
model: "gpt-4.1-mini"
, tools: []
}
task extract_intent(message: String) -> Obj{intent: String, urgency: String} {}
task route(intent: String, urgency: String) -> Obj{queue: String} {}
task respond(intent: String, queue: String) -> Obj{reply: String} {}
Note
Task bodies are always empty
The
{}body is intentional. Task signatures live in the DSL; task behavior is supplied by registered Rust handlers or native adapters at runtime. This separation keeps the language small and the runtime extensible.
Step 3: Write the pipeline
agent triage {
model: "gpt-4.1-mini"
, tools: []
}
task extract_intent(message: String) -> Obj{intent: String, urgency: String} {}
task route(intent: String, urgency: String) -> Obj{queue: String} {}
task respond(intent: String, queue: String) -> Obj{reply: String} {}
pipeline support_reply(message: String) -> String {
let i = run extract_intent with { message: message } by triage;
let q = run route with { intent: i.intent, urgency: i.urgency } by triage;
let r = run respond with { intent: i.intent, queue: q.queue } by triage;
return r.reply;
}
The pipeline:
- takes a
message: Stringas input - runs three tasks in sequence, threading outputs into subsequent inputs
- returns
r.replywhich has the declared return typeString
Step 4: Run it
cargo run -- my_support.agent support_reply \
--input '{"message":"urgent refund request"}'
{
"result": "[triage] Routed as billing to billing-priority."
}
Step 5: Add error handling
What if route fails? Add a retry and fallback:
let q = run route
with { intent: i.intent, urgency: i.urgency }
by triage
retries 2
on_fail use { queue: "general" };
If route fails after 3 total attempts, execution continues with queue: "general" instead of aborting.
What the type checker catches
The type checker runs before execution. Try breaking something — for example, passing the wrong field:
-- wrong: respond expects intent: String, not message: String
let r = run respond with { intent: message, queue: q.queue } by triage;
TypeError: argument 'intent' expected String, got String ✓
-- (this actually would pass since message is String)
Try a real type mismatch:
let r = run respond with { intent: 42, queue: q.queue } by triage;
TypeError: argument 'intent' expected String, got Number
The pipeline never executes — the error is caught statically.
Next steps
- Add a
parallelblock to run tasks concurrently → Parallel Execution - Use
if/elseto branch on task output → Pipelines - Connect to real LLMs → Adapters
Agents
An agent is a named execution profile that binds a model name and a list of tools. Agents don't do anything on their own — they are referenced in pipeline run statements or selected implicitly by workflow stage/review steps to declare which model and tools should handle a task.
Syntax
agent <name> {
[ model: "<model-name>" ]
, tools: [tool_a, tool_b]
}
The model field is optional. When omitted, the agent uses the runtime default.
Example
tool web_search(query: String) -> List[Obj{title: String, url: String, snippet: String}] {}
agent planner {
model: "gpt-4.1"
, tools: [web_search]
}
-- model omitted — uses the selected adapter's default
agent writer {
tools: []
}
Rules
modelis optional. When omitted, the runtime uses the selected adapter's default model or the mock handler.AGL_OPENAI_MODELandAGL_ANTHROPIC_MODELprovide deployment-wide overrides (see Model mapping).toolsis required. Use[]for an empty tool list.- Tool names are identifiers and must be declared with
tooldefinitions in the DSL. - Agent names must be unique within a file.
- Duplicate tool names in the list are a parse error.
Using an agent
Reference an agent in a pipeline run statement with by:
let r = run research with { topic: topic } by planner;
When by is omitted for a standard task handled by a live adapter, the runtime uses that adapter's configured default model. Tasks declared by agent require an explicit agent binding.
In workflows, the agent is attached directly to the step:
stage plan = planner does draft_outline(topic);
Model resolution
| Situation | Model used |
|---|---|
by agent_name present, model declared | agent_name.model from the DSL (mapped in anthropic mode) |
by agent_name present, model omitted | Provider default or AGL_OPENAI_MODEL / AGL_ANTHROPIC_MODEL override |
by omitted, live provider mode | Provider default or deployment override |
by omitted, mock mode | no model needed |
Tools
Tools are identifiers that activate additional adapter behavior in live and anthropic mode.
Currently supported tools:
| Tool | Effect in live/anthropic mode |
|---|---|
web_search | Exposes a typed search tool that model tasks may call at runtime |
fetch_url | Fetches page content and exposes extracted text to model tasks |
In mock mode, tools are parsed and stored but do not trigger external network calls.
Next: Tasks
Tasks
A task is a typed signature that declares the inputs and output of a callable unit of work. Tasks are the nouns of AgentLang — they describe what can be run, but not how it runs.
Syntax
task <name>(param1: Type, param2: Type) -> ReturnType {}
or
task <name>(param1: Type, param2: Type) -> ReturnType by agent {}
Example
task research(topic: String) -> Obj{notes: String} {}
task compare(note_a: String, note_b: String) -> Obj{decision: String} {}
task investigate(topic: String) -> Obj{summary: String, sources: List[String]} by agent {}
Rules
- The body is always
{}. - Standard tasks are supplied by the Rust
Registryor native adapters at runtime. task ... by agent {}declares a model-executed task that must be run with an explicitby <agent>binding.- Task names must be unique within a file.
- Parameter names must be unique within a task.
- All parameters and the return type must use supported types.
Why empty bodies?
This separation between signature and behavior is intentional:
- The DSL stays simple and statically checkable.
- Behavior can be swapped between
mockandliveadapters without changing the.agentsource. - Deterministic tasks and agent-executed tasks can share the same workflow and pipeline language.
Built-in task handlers
The following tasks are available out of the box:
| Task name | Mock behavior | Live/Anthropic behavior |
|---|---|---|
research | Returns placeholder notes | Calls LLM with optional web search |
draft | Returns placeholder article | Calls LLM to write a draft |
compare | Returns placeholder decision | Calls LLM to compare two notes |
respond | Returns placeholder reply | Calls LLM to generate a reply |
llm_complete | Echoes the prompt | Calls LLM with the given prompt |
extract_intent | Returns fixed intent/urgency | Deterministic local handler |
route | Returns routing decision | Deterministic local handler |
flaky_fetch | Fails N times, then succeeds | Deterministic local handler |
Note
extract_intent,route, andflaky_fetchare always deterministic regardless of adapter mode.
Agent tasks
Agent tasks are declared in the DSL and executed by the model bound in the run statement.
task investigate(topic: String) -> Obj{summary: String, sources: List[String]} by agent {}
- They may use the bound agent's declared tools in live/anthropic mode.
- They must still return values that match the declared DSL return type.
- In mock mode, AgentLang returns deterministic placeholder values that satisfy the declared type.
- In live/anthropic mode, the final model output is decoded as JSON and validated against the task's declared return type.
Adding a new task
- Declare the signature in your
.agentfile. - For deterministic native tasks, register a Rust handler in a
Registry. See native extensions for an embedding example. - For model-executed tasks, declare
by agentand rely on the runtime-generated handler path.
See Contributing → Adding a Task for the full checklist.
Next: Workflows
Workflows
A workflow is the high-level authoring surface in AgentLang. It lets you declare stage handoffs and review loops without spelling out the lowered run, while, break, and rebinding logic yourself.
Syntax
workflow <name>(param: Type, ...) -> ReturnType {
<workflow-steps>
}
Step forms
stage
stage artifact = agent_name does task_name(expr1, expr2);
- Arguments are positional
- The argument count must match the task signature
- The stage result becomes a named artifact for later steps
review
review approved_artifact = reviewer checks draft_artifact
revise with reviser using revise_task
max_rounds 2;
This is a declarative review loop. The compiler lowers it to:
- an initial review task call
- a hidden revision budget using
countdown - a
whileloop - a revise task call plus re-review on each failed review round
Current rule:
- the review task name is inferred as
review_<artifact>
So the example above requires a task named review_approved_artifact(...).
Example
agent planner {
model: "gpt-4.1"
, tools: []
}
agent reviewer {
model: "gpt-4.1-mini"
, tools: []
}
task plan(topic: String) -> Obj{outline: String, sources: List[String]} by agent {}
task review_approved_outline(topic: String, outline: String, sources: List[String]) -> Obj{approved: Bool, feedback: String} by agent {}
task revise_outline(topic: String, outline: String, sources: List[String], feedback: String) -> Obj{outline: String, sources: List[String]} by agent {}
workflow publish(topic: String) -> String {
stage draft = planner does plan(topic);
review approved_outline = reviewer checks draft revise with planner using revise_outline max_rounds 2;
return approved_outline.outline;
}
Lowering
Workflows are compiled into ordinary pipelines before type-checking and execution.
Use:
cargo run -- examples/multiagent_blog.agent publish_topic_blog --lower
to inspect the lowered pipeline IR.
When to use workflows vs pipelines
Use workflow when:
- users should author stage handoffs declaratively
- review/revise loops should be implicit
- you want a cleaner source language and an inspectable lowered IR
Use pipeline when:
- you need explicit low-level control flow
- you want to author
parallel,while,break,continue, or custom fallback behavior directly - you are debugging or experimenting with the core execution model
Next: Pipelines
Pipelines
A pipeline is the explicit execution IR in AgentLang. It takes typed inputs, runs a sequence of statements, and returns a typed value. Pipelines are invoked from the CLI directly, and workflows are lowered into pipelines before type-checking and execution.
Syntax
pipeline <name>(param: Type, ...) -> ReturnType {
<statements>
}
Example
pipeline support_reply(message: String) -> String {
let i = run extract_intent with { message: message } by triage;
let q = run route with { intent: i.intent, urgency: i.urgency } by triage;
let r = run respond with { intent: i.intent, queue: q.queue } by triage;
return r.reply;
}
Relationship to workflows
If workflow is the high-level authoring model, pipeline is the explicit lowered form.
Use pipeline when you want direct control over:
parallel { } join(with optionalmax_concurrency)while,break, andcontinueif/if lettry/catchassertquality gates- retry/fallback clauses on individual runs
- pipeline composition (pipeline-calls-pipeline)
Use workflow when you want to declare stage handoffs and review loops without writing the underlying control flow.
Rules
- Pipeline names must be unique within a file.
- Every pipeline must have at least one reachable
return. - Input params are bound from the CLI
--inputJSON. - Every reachable
returnexpression must be assignable to the declared return type.
Statements
let — run a task
let x = run task_name with { key: expr } by agent_name;
Runs a task, binds the result to x. See retry & fallback for optional clauses.
Shorthand syntax is also available:
let x = task_name(expr1, expr2) by agent_name;
Arguments are positional and matched to declared parameter names in order.
parallel { } join — concurrent tasks
parallel {
let a = run research with { topic: query + " A" } by planner;
let b = run research with { topic: query + " B" } by planner;
} join;
Runs all branches concurrently. After join, both a and b are in scope. See Parallel Execution.
if / else — conditional branching
if f.data == "fallback for " + topic {
let d = run draft with { notes: "Fallback path: " + f.data } by ops;
return d.article;
} else {
let d = run draft with { notes: "Fresh path: " + f.data } by ops;
return d.article;
}
Rules:
- The condition must type-check to
Bool. elseis optional.- In AGL 0.3–0.6, an
ifwithoutelseneeds a return on its continuing path. AGL 0.2 retains its permissive fallthrough behavior. - A returning branch does not constrain the scope of the continuing branch. Only normal continuations are merged.
if let — option unwrap
if let value = maybe_result {
return value.name;
} else {
return "missing";
}
if let unwraps Option[T] values and binds the inner value only inside the success branch.
while, break, continue — explicit looping
while state.done == false {
if state.next == 2 {
break;
}
let state = run countdown with { current: state.next } by ops;
}
Use these only when you want direct low-level control. Workflow review loops lower into these constructs automatically.
try / catch — error recovery
try {
let result = run risky_task with { input: data };
} catch err {
let fallback = run safe_task with { query: err };
}
If any statement in the try block raises a runtime error, execution jumps to catch. The error variable is bound as a String. See Error Handling for details.
assert — quality gates
assert final.title != "", "Title must not be empty";
Halts execution with an assertion error if the expression is false. See Testing for usage in test blocks.
Pipeline composition (pipeline-calls-pipeline)
A run statement can target another pipeline:
pipeline sub_task(topic: String, angle: String) -> DraftResult {
let notes = run research with { topic: topic + " — " + angle } by researcher;
let article = draft(notes.notes) by writer;
return article;
}
pipeline main(topic: String) -> String {
let result = run sub_task with { topic: topic, angle: "deep-dive" };
return result.article;
}
return — exit the pipeline
return expr;
Evaluates expr and exits immediately. The type must match the pipeline's declared return type.
Invoking a pipeline from the CLI
cargo run -- <source.agent> <pipeline_name> --input '<json>'
Example:
cargo run -- examples/support.agent support_reply \
--input '{"message":"urgent refund request"}'
{
"result": "[triage] Routed as billing to billing-priority."
}
Input validation
AgentLang validates --input strictly before execution:
# Missing required input
cargo run -- examples/blog.agent blog_post --input '{}'
# Execution error: Pipeline 'blog_post' missing inputs: ['topic'].
# Unknown extra key
cargo run -- examples/blog.agent blog_post \
--input '{"topic":"x","extra":"bad"}'
# Execution error: Pipeline 'blog_post' received unknown inputs: ['extra'].
Values are also type-checked against declared DSL types (String, Number, Bool, List[...], Obj{...}).
To inspect lowered workflow IR, use:
cargo run -- examples/multiagent_blog.agent publish_topic_blog --lower
Next: The Type System
The Type System
AgentLang has a static type system with structural objects and nominal records/unions. The type checker runs after parsing, before execution—type errors are caught before any task handler is invoked.
Primitive types
| Type | Description | Example literals |
|---|---|---|
String | UTF-8 text | "hello", "gpt-4.1" |
Number | Integer or float | 1, 3.14, 0 |
Bool | Boolean | true, false |
Composite types
List[T]
A list where every element has type T:
List[String]
List[Number]
List[Obj{name: String}]
Obj{field: Type, ...}
A structural object type with named fields:
Obj{notes: String}
Obj{intent: String, urgency: String}
Obj{article: String}
Object types are open structural requirements. A value must contain every declared field with a compatible type, but may contain additional fields. Those additional fields remain present at runtime, although they cannot be accessed through a static type that does not declare them.
String escape sequences
String literals support the following escape sequences:
| Escape | Character |
|---|---|
\n | Newline |
\t | Tab |
\r | Carriage return |
\\ | Backslash |
\" | Double quote |
\' | Single quote |
\0 | Null |
\uXXXX | Unicode code point (4 hex digits) |
\UXXXXXXXX | Unicode code point (8 hex digits) |
Operators and type rules
| Operator | Operand types | Result type |
|---|---|---|
+ | String + String | String |
+ | Number + Number | Number |
== | same type on both sides | Bool |
!= | same type on both sides | Bool |
What the type checker verifies
- Every variable reference is bound before use.
- Task argument names and types exactly match the task signature.
ifconditions have typeBool.on_fail usefallback expression type matches the task return type.returnexpression type matches the pipeline's declared return type.- Field access (
x.field) is only allowed on values withObjtype.
Type errors
Examples of errors the type checker catches:
TypeError: argument 'intent' expected String, got Number
TypeError: field 'missing_field' not found on Obj{notes: String}
TypeError: if condition has type String, expected Bool
TypeError: return expression has type Number, pipeline declares String
TypeError: on_fail fallback type Obj{article: String} does not match task return Obj{data: String}
Type aliases
A type alias gives a name to a type expression. Aliases are resolved at parse time — the alias name can be used anywhere a type is expected.
type ResearchNotes = Obj{notes: String, sources: List[String]};
type DraftResult = Obj{article: String, word_count: Number};
Use aliases to avoid repeating complex object types across multiple task signatures:
task research(topic: String) -> ResearchNotes by agent {}
task draft(notes: String) -> DraftResult by agent {}
Alias names must be unique. Aliases cannot be recursive.
Named records
AGL 0.3 adds nominal records. Unlike a transparent alias, a record creates a distinct static type and must be constructed explicitly:
language "0.3";
record Person {
name: String,
active: Bool,
};
pipeline person(name: String) -> Person {
return Person {name: name, active: true};
}
Constructors require exactly the declared fields. Records encode as ordinary JSON objects; nominal identity is checked in AGL source rather than emitted as a runtime tag.
Tagged unions and Result
Closed unions model domain alternatives and structured errors:
union FetchError {
Network { message: String },
Cancelled,
};
task fetch(url: String) -> Result[String, FetchError] {}
Union values carry stable $type and $variant JSON tags. Result[T, E] uses the same representation with Ok(value) and Err(error) constructors. A typed Err is an ordinary value rather than a thrown execution failure.
Use exhaustive matching to consume a union:
match result {
Result::Ok { value } => { return value; }
Result::Err { error } => { return "failed"; }
}
The checker rejects missing or duplicate variants and keeps pattern bindings scoped to their arm.
Enum types
An enum declares a closed set of string variants:
enum ContentTone { formal, conversational, technical };
enum FilingStatus { single, married_joint, married_separate, head_of_household };
Enum values are assignable to String parameters. At runtime, values are validated to be one of the declared variants.
task review_article(article: String, tone: ContentTone) -> ReviewVerdict by agent {}
-- in a pipeline, pass enum values as string literals:
let v = run review_article with { article: merged.article, tone: "formal" } by reviewer;
Enum names must be unique. Variant names must be unique within an enum.
Runtime input type checking
At runtime, the --input JSON is also validated against declared pipeline param types before execution begins:
cargo run -- examples/blog.agent blog_post \
--input '{"topic": 42}'
# Execution error: Pipeline 'blog_post' input 'topic' has invalid value 42 for type String.
Note
BoolvsNumberJSON booleans are distinct from JSON numbers. AgentLang's
Numbertype excludes booleans—passingtruewhere aNumberis expected is a type error.
Next: Parallel Execution
Structured parallel execution
AGL 0.6 has three structured-concurrency forms. Every child is owned by its parent scope and is joined before the enclosing statement continues.
Parallel branches
parallel max_concurrency 2 {
let a = run research with { topic: "angle A" } by planner;
let b = run research with { topic: "angle B" } by planner;
} join;
Branches receive a snapshot of the environment. Their bindings become available after join; one branch cannot observe another branch's writes. max_concurrency bounds the number of active branches in that block. There is no global --workers flag: use per-block limits, task concurrency_group limits, rate limits, and pipeline budgets.
Ordered parallel map
let answers = parallel map item in questions {
let answer = run answer with { question: item } by researcher;
} max_concurrency 4 failure collect_all;
Results preserve input order even when branches finish out of order. failure fail_fast stops scheduling later chunks after a failure; failure collect_all completes the dataset and reports the aggregate failure.
Race
let answer = race {
let fast = run answer_fast with { question: question } by local;
let deep = run answer_deep with { question: question } by remote;
};
The first successful branch wins. Losing branches receive a cooperative cancellation token and are joined before the race statement returns. If every branch fails, the race returns an aggregate failure.
Cancellation and limits
Contextual Rust handlers receive Invocation.cancellation and should check it during network, tool, and long-running work. Runtime limits include task/tool concurrency groups, rate limits, pipeline concurrency budgets, and per-map/per-block bounds. These limits are distinct from provider-side quotas.
See Structured Concurrency, Retries & Budgets, and Runtime & Typing.
Retry & Fallback
AgentLang has first-class syntax for handling transient task failures. You can declare a retry budget and a fallback policy directly in the run statement; the Rust runtime owns attempt accounting, backoff, cancellation, and fallback selection.
Syntax
let x = run task_name
with { key: expr }
by agent_name
retries N
on_fail abort;
let x = run task_name
with { key: expr }
by agent_name
retries N
on_fail use <fallback_expr>;
All clauses are optional. Defaults: retries 0, on_fail abort.
Retry budget
retries N means the task will be attempted up to N + 1 times total.
retries N | Total attempts |
|---|---|
retries 0 (default) | 1 |
retries 1 | 2 |
retries 2 | 3 |
Failure policies
on_fail abort (default)
If the task exhausts its retry budget, a runtime error is raised and the pipeline stops:
Execution error: Task 'flaky_fetch' failed after 3 attempts.
on_fail use <expr>
If the task fails after all retries, the fallback expression is evaluated and bound to x instead:
let f = run flaky_fetch
with { key: topic, failures_before_success: fail_count }
by ops
retries 2
on_fail use { data: "fallback for " + topic };
Warning
Type must match
The fallback expression type must match the task's declared return type. This is enforced statically by the type checker.
Timeout and retries
When a task has both retries and timeout clauses, a timeout cancels the invocation and is non-retryable. A contextual handler should observe its cancellation token and stop promptly; the runtime waits briefly for cooperative shutdown before returning the timeout failure. This prevents overlapping attempts and unsafe duplicated side effects.
The on_fail use fallback still applies after a timeout. If the task times out and a fallback expression is declared, the fallback value is used instead of raising an error.
Full example
From examples/reliability.agent:
pipeline resilient_brief(topic: String, fail_count: Number) -> String {
let f = run flaky_fetch
with { key: topic, failures_before_success: fail_count }
by ops
retries 2
on_fail use { data: "fallback for " + topic };
if f.data == "fallback for " + topic {
let d = run draft with { notes: "Fallback path: " + f.data } by ops;
return d.article;
} else {
let d = run draft with { notes: "Fresh path: " + f.data } by ops;
return d.article;
}
}
Run it — succeeds within retry budget:
cargo run -- examples/reliability.agent resilient_brief \
--input '{"topic":"api-status","fail_count":1}'
{
"result": "[ops] Draft article:\nFresh path: [ops] fetched payload for api-status"
}
Run it — exhausts retries, uses fallback:
cargo run -- examples/reliability.agent resilient_brief \
--input '{"topic":"api-status","fail_count":5}'
{
"result": "[ops] Draft article:\nFallback path: fallback for api-status"
}
Detecting fallback use
After on_fail use, the bound variable holds the fallback value. You can inspect it with == to detect which path was taken:
if f.data == "fallback for " + topic {
-- fallback path
} else {
-- success path
}
This is a common pattern — compute a sentinel fallback value and check for it downstream.
Next: Error Handling
Effects and idempotency
AGL 0.4 makes operational capabilities visible to the compiler. Types describe data; effects describe what executing a task may do.
language "0.4";
task publish(id: String, article: String) -> String
effects [network, external_write]
idempotency keyed_by id {}
pipeline release(id: String, article: String) -> String
effects [network, external_write] {
let publication = publish(id, article) retries 2;
return publication;
}
Built-in effects
| Effect | Meaning |
|---|---|
model | Calls a generative model |
network | Uses the network |
filesystem | Reads or writes host files |
external_read | Reads external state |
external_write | Mutates external state |
secret | Reads secret material |
human | Suspends for human input or approval |
Applications may introduce capability names for deployment policy. Custom names do not weaken built-in rules.
Pipeline effects are transitive through task, agent-tool, and pipeline calls. An omitted pipeline clause is inferred. When a pipeline declares effects [...], the list is an allowed ceiling and missing capabilities are a static error.
Retry safety
pure, idempotent, keyed_by, and non_idempotent document whether repeated execution is safe. A retried external write must be idempotent or keyed. The key parameter must be a String.
retry_on remains orthogonal: it selects which typed domain errors cause another attempt, while idempotency determines whether another attempt is safe at all.
See Retries, invocation identity, and budgets for backoff, stable keys, usage accounting, and scoped limits.
Retries, invocation identity, and budgets
AGL 0.4 treats a retry as another attempt at one logical invocation. Every invocation receives an execution ID, invocation ID, and idempotency key. Those values remain stable across attempts and are emitted in task_attempt and task_retry trace events. A keyed_by task uses the declared string argument as its adapter-visible idempotency key.
let receipt = publish(order_id)
retries 3
backoff { initial_ms: 100, max_ms: 2000, multiplier: 2, jitter: 0.2 };
Backoff is capped exponential delay. Jitter is a fraction from zero to one. Production contexts sleep for the computed delay; deterministic test/evaluation contexts use a seed and record the same delay without sleeping.
A pipeline can place a scoped ceiling on resources:
pipeline release(order_id: String) -> String
budget {
time_ms: 10000,
tokens: 20000,
cost_usd: 0.50,
tool_calls: 8,
retries: 3,
concurrency: 2
} {
let receipt = publish(order_id);
return receipt;
}
Parallel branches atomically share their enclosing pipeline's counters. A nested pipeline starts a nested scope. Runtime crossings reserve calls and retries before starting work and account reported provider tokens and cost after a response. Exceeding any ceiling produces a structured Failure with kind = "budget" and the resource name in operation; it is also visible in the trace.
Native handlers that can report usage should use Registry::register_contextual and return TaskOutput. Legacy handlers remain supported and report zero usage.
Deterministic evaluation
AGL 0.4 keeps ordinary test blocks fast and deterministic and adds dataset-driven eval declarations for quality and operational regressions.
eval answer_regression {
pipeline: answer,
dataset: "eval/answers.jsonl",
trials: 5,
baseline: "eval/answers.baseline.json",
assert_schema: true,
assert_expected: true,
max_latency_ms: 2000,
max_cost_usd: 0.02,
semantic_grader: grade_answer
}
The dataset is JSON Lines. Each row contains an input object and may contain an expected value. assert_expected is the built-in exact predicate. A semantic grader names a Bool-returning pipeline and receives actual and expected inputs.
Run an evaluation with agl program.agent --eval answer_regression. A report contains pass rate plus mean, p50, p95, minimum, and maximum latency and cost. Trials use deterministic seeds, so mock handlers and retry jitter are reproducible. Registry::from_trace creates replay handlers from recorded task results without contacting a provider.
Baseline files declare min_pass_rate, max_mean_latency_ms, and max_mean_cost_usd. Any regression makes the command fail, which makes it suitable for CI. Use --update-baseline only when intentionally accepting the current report.
Structured concurrency
AGL 0.5 owns concurrent work as a scope. Ordinary parallel, ordered parallel map, and race join their children before leaving the statement. race sends cooperative cancellation to losers.
let summaries = parallel map document in documents max_concurrency 4 fail_fast {
let summary = summarize(document);
};
let answer = race {
let primary = ask_primary(question);
let backup = ask_backup(question);
};
parallel map preserves input ordering regardless of completion order. fail_fast stops scheduling later chunks after a failure; collect_all completes the dataset and aggregates failures. Direct task calls keep cancellation and durable identity unambiguous in 0.5.
Task/tool declarations can share a scheduler contract with concurrency_group api concurrency_limit 4 rate_limit 10. Pipeline budget { concurrency: ... } still caps a local scope, while named groups coordinate calls across scopes.
Durable workflows and approvals
Use a SQLite event store and stable execution ID when a workflow may outlive one process:
agl release.agent release --event-store runs.sqlite --execution-id release-42
If the pipeline reaches approve production "Release?", it persists a human_suspended event and exits with a structured suspension. Resume it with the same source, deployment, store, and ID:
agl release.agent release --event-store runs.sqlite --execution-id release-42 \
--resume --approval production=true
Completed task results are replayed by invocation ID, so the work before the approval is not repeated. A source or deployment fingerprint mismatch stops resume. SQLite histories are schema-versioned, configured secrets are redacted before append, and hosts can apply retention with EventStore::prune_before.
Approval decisions are typed booleans and audited with actor/time metadata. expires bounds how long a suspended decision remains valid; delegate records the intended decision role.
Modules and packages
An AGL 0.6 module exports only declarations marked public:
language "0.6";
import text from "text.agent";
public pipeline report(input: String) -> String {
let output = text::normalize(input);
return output;
}
Paths resolve relative to the importer. Qualified names are deterministic, cycles show their full path, private imports fail, and .agl-cache stores validated public interfaces.
Packages use agl.json and a deterministic agl.lock. Run agl package lock agl.json. Local dependencies are hashed from their contents. Git dependencies must pin a revision and integrity. Generate an API snapshot with --api api.json; compare releases with agl api-compare previous.json current.json.
Editor and build tooling
agl file.agent --formatprints canonical source.agl file.agent --docs api.mdgenerates exported API docs with inferred effects.agl protocolserves the JSON-lines compiler protocol.agl lspserves LSP over stdio with diagnostics, completion, hover, definition, references, rename, and formatting.agl completions bash|zsh|fishemits shell completions.tree-sitter-aglcontains the incremental grammar and highlight query.
The protocol is intentionally independent from an editor so build systems can consume the same structured check, lower, format, and effects results.
Extensions and deployment security
Native Rust extensions implement version-1 traits for tasks, tools, model adapters, policies, event stores, or graders and register at compile time. API descriptors fail before registration when versions differ.
Deployment policy JSON can allow effects/tools, constrain network hosts and filesystem roots, and require approval around external writes. --summary makes external writes and approval boundaries visible before execution. Trace and durable history redaction occurs before persistence.
Trust boundaries are explicit: native Rust shares the host process and is fully trusted; a future WASI boundary is deferred until the native contracts stabilize and real untrusted-extension requirements emerge.
Agent requirements and deployment bindings
AGL 0.4 separates what an agent program needs from which provider happens to satisfy it.
Source declares portable requirements:
agent researcher {
tools: [],
requires: [reasoning, tool_calling],
min_context: 100000,
max_latency_ms: 5000,
quality: "high"
}
A deployment JSON file selects operational details:
{
"agents": {
"researcher": {
"provider": "openai",
"model": "gpt-5.6-sol",
"reasoning_effort": "medium",
"capabilities": ["reasoning", "tool_calling"],
"context_window": 200000,
"expected_latency_ms": 3000,
"quality": "frontier"
}
}
}
Run with:
cargo run -- examples/deployment_requirements.agent answer \
--adapter openai \
--deployment examples/deployment.openai.json \
--input '{"topic":"typed agent runtimes"}'
The compiler/runtime validates every source agent has a binding, provider matches the selected adapter, capabilities are present, context is large enough, latency is within the ceiling, and quality meets the requested tier. Validation occurs before registry construction or a provider call.
Bindings also supply an optional endpoint and reasoning effort. A deployment currently uses at most one endpoint per provider. API keys remain environment variables and never belong in deployment files.
The source-level model field remains a compatibility escape hatch when no deployment file is supplied. Deployment bindings and environment model overrides let production systems upgrade models without editing language source.
Error Handling
AgentLang provides try/catch blocks for recovering from runtime errors within a pipeline.
Syntax
try {
<statements>
} catch <error_var> {
<statements>
}
How it works
- The
tryblock executes its statements in order. - If any statement raises a runtime error (task failure, assertion, etc.), execution immediately jumps to the
catchblock. - The error variable is bound as a
Stringcontaining the error message. - If the
tryblock completes without error, thecatchblock is skipped entirely. Return, break, and continue propagate through the try block.
Scope rules
- The error variable (
<error_var>) is only in scope inside thecatchblock. A shadowed value is restored on every exit, including when the handler itself fails. - Variables bound before the
tryblock can be re-bound inside either thetryorcatchblock. Rebindings before a failure persist into catch; they are not rolled back. The checker permits catch to read only names with compatible types at every possible failure point. After the construct, only normally completing paths contribute to the merged environment.
Example
From examples/showcase_all_features.agent:
try {
let enrichment = run risky_enrich with { topic: topic };
let merged = run merge_drafts with {
draft_a: merged.article,
draft_b: enrichment.extra,
word_count_a: merged.total_words,
word_count_b: 100
};
} catch err {
-- gracefully degrade: use fallback enrichment
let fallback = run fallback_enrich with { query: err };
let merged = run merge_drafts with {
draft_a: merged.article,
draft_b: fallback.extra,
word_count_a: merged.total_words,
word_count_b: 50
};
}
Here, risky_enrich may fail at runtime. If it does, the catch block uses fallback_enrich with the error message as input, and re-binds merged so downstream code sees the fallback result.
When to use try/catch vs retries/on_fail
| Mechanism | Use when |
|---|---|
retries N | The same task might succeed on retry (transient failures) |
on_fail use <expr> | You have a static fallback value for a single task |
try/catch | You need to run different logic on failure, or the recovery involves multiple steps |
try/catch is more powerful — it wraps an arbitrary block of statements, not just a single task call. Use retries/on_fail for simple cases and try/catch for complex error recovery.
Next: Testing
Testing
AgentLang has built-in support for in-language testing via assert statements and test blocks.
Assert statement
assert <expr>, "<message>";
Evaluates the expression (must be Bool). If false, execution halts with an assertion error containing the message.
assert final.title != "", "Title must not be empty";
assert result.score == 100, "Score should be 100";
Asserts can appear in pipelines (as quality gates) or in test blocks (as test expectations).
Test blocks
test "<name>" {
<statements>
}
Test blocks are top-level declarations alongside agents, tasks, and pipelines. They contain pipeline-like statements (let, run, assert, etc.) and run in an isolated scope.
Test blocks are only executed when the --test flag is passed. During normal pipeline execution, they are parsed and type-checked but not run.
Example
From examples/showcase_all_features.agent:
test "merge_drafts combines two drafts" {
let result = run merge_drafts with {
draft_a: "Part A.",
draft_b: "Part B.",
word_count_a: 100,
word_count_b: 200
};
assert result.total_words == 300, "Total words should be sum of both";
assert result.article != "", "Merged article should not be empty";
}
test "fallback_enrich produces content" {
let result = run fallback_enrich with { query: "test query" };
assert result.extra != "", "Fallback should produce non-empty extra content";
}
test "countdown reaches done" {
let state = run countdown with { current: 3 };
assert state.next == 2, "3 -> next should be 2";
assert state.done == false, "Should not be done at next=2";
let state = run countdown with { current: state.next };
assert state.next == 1, "2 -> next should be 1";
let state = run countdown with { current: state.next };
assert state.next == 0, "1 -> next should be 0";
assert state.done == true, "Should be done when next=0";
}
Running tests
cargo run -- examples/showcase_all_features.agent --test
The --test flag runs all test blocks in the file. No pipeline name is needed.
Output format
Each test prints its name and pass/fail status:
PASS merge_drafts combines two drafts
PASS fallback_enrich produces content
PASS countdown reaches done
3 passed, 0 failed
Exit codes
| Code | Meaning |
|---|---|
0 | All tests passed |
1 | One or more tests failed |
Using native handlers
Test blocks use the same task registry as normal execution. In a Rust host, register custom handlers on Registry and pass it to agl::run_tests along with the program and execution context. The showcase test blocks use built-in native handlers.
Next: Native handlers
Native handlers
AGL applications extend the runtime through Rust registries. Task declarations specify types in the DSL; a host supplies their implementations before execution.
Register a task
let mut registry = agl::Registry::default();
registry.register("uppercase", |args, _agent| {
let text = args["text"].as_str().unwrap_or_default();
Ok(serde_json::json!({"text": text.to_uppercase()}))
});
Pass the registry to agl::execute_pipeline or agl::run_tests. Use
Registry::register_contextual when a handler needs invocation identity,
cancellation, usage reporting, or structured failures.
The executable example includes parsing, checking, registration, and execution:
cargo run --example native_embed
Register a tool
Model-callable tools use agl::adapters::tools::ToolRegistry. Register their
handlers before passing the tool registry to
agl::stdlib::registry_for_with_tools. Declare tool signatures in the DSL so
arguments and results can be validated.
Execution boundary
Custom handlers are compiled into a Rust host application. The stock CLI uses built-in handlers and native provider adapters. It does not load external handler modules. Native handlers share the host process and its permissions.
See native extensions for extension contracts and testing for in-language test blocks.
Observability
The Rust runtime records structured TraceEvent values in ExecutionContext. Each event has a kind, a millisecond timestamp, and a JSON fields object.
CLI tracing
Write a trace after a run:
cargo run -- examples/blog.agent blog_post \
--input '{"topic":"AI safety"}' --output-trace trace.json
Live provider request/tool activity goes to stderr with --trace-live. This is separate from the persisted execution trace.
Event kinds
The runtime emits kinds including:
| Kind | Typical fields |
|---|---|
task_attempt | task, agent, attempt, execution_id, invocation_id, idempotency_key |
task_result | task, attempt, execution_id, invocation_id, result |
task_retry | task, attempt, reason, delay_ms, invocation_id, idempotency_key |
task_replayed | task, execution_id, invocation_id, idempotency_key |
task_cancelled | task, execution_id, invocation_id, reason |
provider_usage | task, attempt, execution_id, invocation_id, usage |
race_winner | branch, cancelled_losers |
human_suspended | approval, prompt, delegate, expires_seconds |
human_approval | approval, decision, actor, decided_at_ms |
The schema is intentionally an extensible (kind, fields) envelope. Consumers should ignore unknown kinds and use the event schema version recorded by durable stores.
Durable history and redaction
With --event-store PATH, events and checkpoints are persisted in the local SQLite-backed event store. --resume loads prior task results by stable invocation identity. Secret values configured on an ExecutionContext are redacted before trace and durable persistence.
Rust embedding
let context = agl::context::ExecutionContext::default();
let value = agl::execute_pipeline(&program, "pipeline", inputs, ®istry, &context)?;
for event in context.events() {
println!("{} {} {}", event.kind, event.timestamp_ms, event.fields);
}
Use ExecutionContext::deterministic(seed) for non-sleeping, reproducible retry/evaluation runs and ExecutionContext::durable(execution_id, store, resume) for replayable execution.
What to measure
- task attempts, retries, cancellations, and replay hits;
- provider usage and cost inputs returned in
TaskOutput; - approval suspension, decision, delegation, and expiry;
- race winners and parallel-map failures;
- source/deployment fingerprints at durable-run boundaries.
Language Reference
Complete syntax reference for AGL 0.2 through 0.6. The Rust parser and checker are the executable reference; the versioned normative contracts live in spec/agl-0.2.md through spec/agl-0.6.md.
File structure
An .agent file may begin with language "0.2"; through language "0.6";, then contains declarations. When omitted, the current language version is assumed. Unsupported versions are rejected rather than silently reinterpreted. AGL 0.3 adds named outcomes, 0.4 effects/deployments/evaluation, 0.5 durable structured concurrency, and 0.6 modules/packages/tooling.
language "0.2";
-- type aliases and enums
type Notes = Obj{notes: String};
enum Tone { formal, casual };
-- tool declarations
tool web_search(query: String) -> List[Obj{title: String, url: String, snippet: String}] {}
-- agent declarations (model is optional)
agent planner { model: "gpt-4.1", tools: [web_search] }
agent writer { tools: [] }
-- task signatures
task research(topic: String) -> Notes {}
task draft(notes: String) -> Obj{article: String} {}
-- pipeline definitions
pipeline blog_post(topic: String) -> String {
let r = run research with { topic: topic } by planner;
let d = run draft with { notes: r.notes } by writer;
return d.article;
}
-- workflow definitions
workflow publish_topic_blog(topic: String) -> String {
stage plan = planner does research(topic);
stage draft = writer does draft(plan.notes);
return draft.article;
}
-- test blocks
test "research returns notes" {
let r = run research with { topic: "test" };
assert r.notes != "", "notes should not be empty";
}
For new code, start with language "0.6";. The versioned normative specifications live in spec/agl-0.2.md through spec/agl-0.6.md.
AGL 0.3 records, unions, and matching
language "0.3";
record Article { title: String, body: String };
union PublishError {
Rejected { reason: String },
Unavailable,
};
task publish(article: Article) -> Result[String, PublishError] {}
pipeline publish_title(article: Article) -> String {
let outcome = publish(article);
match outcome {
Result::Ok { value } => { return value; }
Result::Err { error } => {
match error {
PublishError::Rejected { reason } => { return reason; }
PublishError::Unavailable => { return "unavailable"; }
}
}
}
}
Record constructors provide every declared field. Union constructors use Type::Variant and carry declared fields. match must cover every variant exactly once. Pattern fields bind same-named variables within the selected arm.
type alias declaration
type <Name> = <type>;
Defines a named alias for a type expression. Aliases are resolved at parse time — everywhere <Name> appears in a type position, it is replaced by the underlying type.
type Notes = Obj{notes: String, sources: List[String]};
type Profile = Obj{name: String, score: Number};
Constraints: alias names must be unique. Aliases cannot be recursive.
enum declaration
enum <Name> { variant1, variant2, ... };
Defines a closed set of string variants. Enum values are assignable to String parameters. At runtime, the value is validated to be one of the declared variants.
enum Tone { formal, conversational, technical };
enum Status { pending, approved, rejected };
Constraints: enum names must be unique in the type namespace. Variant names are globally unique in 0.2/0.3 so string-literal inference is unambiguous.
agent declaration
agent <name> {
[ model: "<string>" ]
, tools: [ <id>, ... ]
}
| Field | Required | Description |
|---|---|---|
model | No | Model name string literal (defaults to None) |
tools | Yes | List of tool identifiers (may be empty []) |
When model is omitted, the native runtime uses its provider default. Set AGL_OPENAI_MODEL or AGL_ANTHROPIC_MODEL to override model routing globally for a deployment. Legacy OpenAI model declarations are mapped by capability tier when using a native provider adapter.
-- with explicit model
agent planner { model: "gpt-4.1", tools: [web_search] }
-- without model (uses default)
agent writer { tools: [] }
Constraints: agent names unique, tool names unique within the list.
Agent tool names must refer to declared tool definitions.
tool declaration
tool <name>( <params> ) -> <type> {}
| Part | Description |
|---|---|
<name> | Unique identifier |
<params> | Comma-separated name: Type pairs (may be empty) |
-> <type> | Return type |
{} | Reserved for runtime-provided tool behavior |
Constraints: tool names unique, parameter names unique within a tool.
task declaration
task <name>( <params> ) -> <type> {}
or
task <name>( <params> ) -> <type> by agent {}
| Part | Description |
|---|---|
<name> | Unique identifier |
<params> | Comma-separated name: Type pairs (may be empty) |
-> <type> | Return type |
by agent | Marks the task as model-executed instead of handler-executed |
{} | Always empty — behavior is provided by runtime handlers |
Constraints: task names unique, parameter names unique within a task.
Agent tasks must be run with an explicit by <agent> binding in pipeline statements.
pipeline declaration
pipeline <name>( <params> ) -> <type> {
<statements>
}
Constraints: pipeline names unique, at least one reachable return, and every reachable return assignable to the declared result type. AGL 0.3–0.6 also reject normal fallthrough; 0.2 permits it until the runtime boundary.
workflow declaration
workflow <name>( <params> ) -> <type> {
<workflow-steps>
}
workflow is the high-level authoring surface. It compiles to an ordinary pipeline before type-checking and execution. Use cargo run -- <file> <name> --lower to inspect the lowered pipeline IR.
Constraints: workflow names unique, at least one return, and workflow names may not collide with pipeline names.
Workflow steps
Stage step
stage <artifact> = <agent> does <task>( <expr>, ... );
Arguments are positional and must match the task's declared parameter order.
Review step
review <artifact> = <reviewer> checks <source>
revise with <reviser> using <task>
max_rounds <N>;
This is a declarative review loop:
- The workflow compiler infers the review task name as
review_<artifact>. - The inferred review task is expected to return an object with at least
approved: Boolandfeedback: String. - The revise task must return the same object type as
<source>. - The source artifact is consumed by the review step and replaced by the final reviewed artifact name.
max_roundssets the revision budget; looping is handled internally in the lowered pipeline.
Workflow return
return <expr> ;
Workflow return expressions use the same expression grammar as pipelines.
Statements
Run statement
let <x> = run <task>
with { <key>: <expr>, ... }
[ by <agent> ]
[ retries <N> ]
[ retry_on [<ErrorType>::<Variant>, ...] ]
[ timeout <N> ]
[ on_fail abort | on_fail use <expr> ]
;
| Clause | Default | Description |
|---|---|---|
by <agent> | none | Agent binding for model + tool resolution |
retries N | 0 | Retry budget (N+1 total attempts) |
retry_on [...] | all host failures | Retry only the listed typed Err variants; requires Result[T, E] and retries N |
timeout N | none | Deadline in seconds; handler is abandoned if exceeded |
on_fail abort | default | Raise error on exhaustion |
on_fail use <expr> | — | Use fallback value on exhaustion |
Constraints: argument keys must exactly match task parameter names. Duplicate argument keys are a parse error.
Shorthand run
let <x> = <task>( <expr>, ... ) [ by <agent> ];
Syntactic sugar for let <x> = run <task> with { ... }. Arguments are positional and matched to declared parameter names in order.
let article = draft(notes.notes) by writer;
-- equivalent to:
let article = run draft with { notes: notes.notes } by writer;
Parallel block
parallel [ max_concurrency <N> ] {
let <a> = run <task> with { ... } [ by <agent> ] [ retries N ] [ on_fail ... ];
let <b> = run <task> with { ... } [ by <agent> ] [ retries N ] [ on_fail ... ];
} join;
Only let ... = run ...; statements are permitted inside. All bindings from inside the block are available after join.
The optional max_concurrency N limits how many branches run simultaneously within this block. There is no global worker flag; task concurrency groups, rate limits, and pipeline budgets provide broader controls.
Conditional
if <expr> {
<statements>
} [ else {
<statements>
} ]
Condition must have type Bool. else is optional.
While loop
while <expr> {
<statements>
}
Condition must have type Bool. The loop body may contain the same statement forms as a pipeline block. Normal and continue back-edges must preserve the entry binding types. The post-loop scope merges the zero-iteration path with break exits; incompatible bindings are unavailable afterward.
Break / Continue
break;
continue;
break exits the nearest enclosing while. continue skips to the next iteration of the nearest enclosing while. Both are only valid inside loop bodies.
Option unwrap conditional
if let <x> = <expr> {
<statements>
} [ else {
<statements>
} ]
<expr> must have type Option[T]. In the then branch, <x> is bound as T. If the option value is null, the else branch runs instead. else is optional.
Return
return <expr> ;
Expression type must match the pipeline's declared return type.
Try / catch
try {
<statements>
} catch <error_var> {
<statements>
}
AGL 0.3 can bind a structured execution failure instead of a legacy message string:
try {
let result = risky_task(input);
return result;
} catch failure: Failure {
return failure.kind + ": " + failure.message;
}
Failure exposes kind, message, operation: Option[String], and retryable. Typed Result::Err values are ordinary domain outcomes and are not caught.
If any statement in the try block raises a runtime error, execution jumps to the catch block. The <error_var> is bound as a String containing the error message. The catch begins with bindings from partial execution, not a rolled-back entry scope. Only names compatible at every possible failure prefix are available to the handler. The catch variable is restored or removed on every exit; return, break, and continue propagate through successful try blocks. Only normally completing paths contribute to the post-block scope.
try {
let result = run risky_task with { input: data };
} catch err {
let fallback = run safe_task with { query: err };
}
Assert
assert <expr>, "<message>";
Evaluates <expr> — if it is false, execution halts with an assertion error containing <message>. The expression must have type Bool.
assert final.title != "", "Title must not be empty";
Test block
test "<name>" {
<statements>
}
Test blocks are top-level declarations that run only when the --test flag is passed. Each test block has its own scope. Tests may contain run, let, assert, and other pipeline statements. Failed assertions cause test failure.
test "draft produces article" {
let d = run draft with { notes: "test notes" };
assert d.article != "", "Article should not be empty";
}
Pipeline call (pipeline-calls-pipeline)
A run statement can target another pipeline instead of a task:
let result = run sub_pipeline with { topic: topic, angle: "deep-dive" };
The target pipeline's parameters and return type are checked identically to task calls. This enables modular composition of pipelines.
Expressions
| Form | Description |
|---|---|
"string" | String literal |
123, 3.14 | Number literal |
true, false | Bool literal |
null | Null literal (assignable to Option[T]) |
x | Variable reference |
x.field | Object field access |
{ key: expr, ... } | Object literal |
[expr, expr] | List literal |
expr + expr | String or number addition |
expr == expr | Equality (same type) |
expr != expr | Inequality (same type) |
Duplicate keys in object literals are a parse error.
Types
| Type | Syntax |
|---|---|
| String | String |
| Number | Number |
| Bool | Bool |
| List | List[T] |
| Option | Option[T] |
| Object | Obj{field: Type, field: Type} |
| Enum | EnumName (declared via enum) |
| Type alias | AliasName (declared via type) |
Enum values are assignable to String parameters. Type aliases are resolved at parse time.
Duplicate field names in Obj types are a parse error.
Comments
-- this is a single-line comment
Only -- line comments are supported. Block comments are not supported in v0.
AGL 0.4 effects, idempotency, and deployment
Tasks and pipelines may declare operational capabilities and retry identity:
language "0.4";
task publish(id: String, body: String) -> String
effects [network, external_write]
idempotency keyed_by id {}
Built-in effects include model, network, filesystem, external_read, external_write, secret, and human. Effects are inferred transitively and can be inspected with --effects.
AGL 0.5 structured concurrency and approvals
AGL 0.5 adds ordered parallel map, race, scheduler groups, resource budgets, and typed human approvals:
language "0.5";
let approved = approve release "Release to production?" expires 3600 delegate oncall;
let results = parallel map item in inputs max_concurrency 4 collect_all {
let result = run classify with { value: item };
};
Approvals suspend a durable run until a host supplies --approval release=true or --approval release=false.
AGL 0.6 modules and packages
Imports are relative to the importing file. Declarations are private by default; imported declarations must be public and are referenced through an alias:
language "0.6";
import text from "text.agent";
public pipeline report(input: String) -> String {
return text::normalize(input);
}
Package manifests use agl.json; agl package lock agl.json creates a deterministic agl.lock. Use --api to snapshot the public interface and agl api-compare to check compatibility before a release.
Language migration guide: 0.3 to 0.6
Language versions are opt-in. Existing explicitly versioned files retain their behavior.
0.3 to 0.4
Add language "0.4"; to use effects, idempotency, deployment requirements, budgets, or evaluations. Existing tasks default to unspecified effects/idempotency. Declare external writes accurately before adding retries; unsafe retries become static errors. Move provider/model settings into deployment JSON when portability matters.
0.4 to 0.5
Add language "0.5"; for parallel map, race, task/tool scheduler limits, and approve. Contextual native handlers should observe Invocation.cancellation. Durable runs should supply both --event-store and a stable --execution-id; resume must use unchanged source and deployment.
0.5 to 0.6
Add language "0.6"; for imports and packages. Visibility now defaults to private, so mark imported APIs public. Replace cross-file global names with alias::name, create agl.json, and check in the generated agl.lock. Generate an API snapshot before a release so breaking changes can enforce a major version bump.
Native extensions declare extension API version 1.
Rust-only implementation
The Python implementation, package, CLI, and task/tool subprocess bridge have been removed. This is a breaking host API change: --plugin, agl::plugins, PYTHON_PROTOCOL_VERSION, and ProtocolEnvelope are no longer available. Language version headers do not restore these host features.
Run programs with agl <source> <pipeline> --input <json>. Move custom task handlers into a Rust Registry and model-callable tool handlers into ToolRegistry; use a Rust host application to register them before execution. See native extensions. Run in-language test blocks against those registries, and use cargo bench --bench runtime for the maintained benchmark.
Control-flow correctness corrections (all versions)
These fixes apply to 0.2–0.6 without a new syntax version:
return,break, andcontinueinside a successfultrynow propagate.- Optional, match, and catch bindings restore shadowed values on every exit.
- Every reachable return must match the pipeline result type. An unrelated valid return no longer hides a mismatch.
- Parallel branches cannot depend on sibling results. Move dependent calls after the join, or execute them sequentially.
- Loop back-edges must preserve entry binding types; break exits participate in the post-loop scope. Use a separate variable when changing a value's type.
- Catch can observe partial execution, so a name must have compatible types at every potential failure prefix to be readable there. Avoid changing an existing variable's type inside a try block; bind the new type to a separate name.
Returning branches no longer hide names supplied by continuing branches. AGL 0.2 still permits fallthrough; 0.3–0.6 require a return on every normally completing path. Diagnostic code AGL2001 is unchanged, but return errors now point to the incompatible expression. Incompatible code may stop passing --check; runtime value encodings and public Rust APIs are unchanged.
CLI reference
The native executable is agl. During development, invoke it with cargo run --; after installation, use agl directly. Mock mode is deterministic and does not require credentials.
Run, check, and inspect a source file
agl <source.agent> [pipeline-or-workflow] [options]
The source file is parsed, imported modules are loaded, diagnostics are emitted, and the program is statically checked before execution. A pipeline/workflow name is required for execution, --lower, and --effects; it is not required for --check, --test, --format, --docs, --api, or --summary.
| Flag | Description |
|---|---|
--input JSON | Object containing pipeline inputs; default {}. |
--check | Parse, load imports, analyze, and type-check without executing. |
--test | Run every test block in the source file. |
--format | Print canonical AGL source and exit. |
--lower | Print the selected pipeline/workflow after workflow lowering. |
--effects | Print inferred transitive effects as JSON. |
--summary | Print effects, external writes, and approval boundaries as JSON. |
--docs PATH | Write generated Markdown API documentation. |
--api PATH | Write a machine-readable public API snapshot. |
--deployment PATH | Load provider-neutral agent bindings from JSON. |
--policy PATH | Enforce effect, tool, network, and filesystem policy. |
--adapter mock|openai|live|anthropic | Select the task/model adapter. live is an OpenAI compatibility alias. |
--trace-live | Emit model/tool trace lines to stderr. |
--output-trace PATH | Write the structured execution trace to a JSON file. |
--event-store PATH | Persist durable events and checkpoints in the local SQLite-backed store. |
--execution-id ID | Choose a stable durable execution identity; requires --event-store. |
--resume | Replay checkpoints for the selected durable execution; requires --event-store. |
--approval NAME=BOOL | Supply an approval or rejection; may be repeated. |
--eval NAME | Run a declared dataset evaluation. |
--update-baseline | Replace the selected evaluation baseline; requires --eval. |
Examples:
cargo run -- examples/blog.agent blog_post \
--input '{"topic":"agent memory patterns"}'
cargo run -- examples/showcase_all_features.agent --check
cargo run -- examples/showcase_all_features.agent --test
cargo run -- examples/multiagent_blog.agent publish_topic_blog --lower
cargo run -- examples/blog.agent blog_post --effects
cargo run -- examples/blog.agent blog_post --summary
cargo run -- examples/blog.agent blog_post --output-trace trace.json
Input keys and values are checked against the declared pipeline signature before execution. Errors use stable diagnostic codes and include source locations where available.
REPL
cargo run -- repl --adapter mock
The REPL is stateful. Load a source file, then run a named pipeline:
AGL REPL (adapter=mock). Type 'help' for commands, 'exit' to quit.
> load examples/blog.agent
Loaded 'examples/blog.agent': 2 agents, 2 tasks, 1 pipelines.
> run blog_post {"topic":"agent memory"}
{
"result": "[writer] Draft article:\n[planner] key points for 'agent memory'"
}
Commands are load <path>, run <name> [json], lower <name>, list, clear, help, and exit. REPL options also accept --deployment and --trace-live.
Tooling subcommands
These commands are intercepted by the native binary and do not use the source-file CLI:
agl protocol # JSON-lines compiler protocol over stdin/stdout
agl lsp # LSP server over stdio
agl completions bash # bash completion script (also zsh and fish)
agl package lock agl.json # write agl.lock
agl api-compare old.json new.json
The protocol accepts structured parse/check/format/lower/effects requests. The LSP server supports diagnostics, completion, hover, definition, references, rename, and formatting.
Live adapters and environment
export OPENAI_API_KEY="sk-..."
cargo run -- examples/incident_runbook.agent respond_to_incident \
--adapter openai --trace-live \
--input '{"incident":"database failover drill"}'
export ANTHROPIC_API_KEY="sk-ant-..."
cargo run -- examples/incident_runbook.agent respond_to_incident \
--adapter anthropic --trace-live \
--input '{"incident":"database failover drill"}'
| Variable | Meaning |
|---|---|
OPENAI_API_KEY | Credential for the OpenAI adapter. |
ANTHROPIC_API_KEY | Credential for the Anthropic adapter. |
AGL_OPENAI_MODEL | Optional OpenAI model override. |
AGL_ANTHROPIC_MODEL | Optional Anthropic model override. |
Never put credentials in .agent files or deployment/policy JSON.
Exit status
0 means the requested operation succeeded. 1 means a source, type, runtime, provider, policy, or evaluation failure. 2 means invalid CLI arguments or an unsupported completion shell.
Adapters
Adapters determine how task handlers are executed. AgentLang ships with deterministic mock, OpenAI (openai, with live as a compatibility alias), and Anthropic (anthropic) modes. Both provider adapters support typed tool calling.
Mock adapter (default)
Mock mode uses deterministic handlers in the Rust standard library and runtime. No external API calls are made.
cargo run -- examples/blog.agent blog_post \
--input '{"topic":"agent memory patterns"}'
{
"result": "[writer] Draft article:\n[planner] key points for 'agent memory patterns'"
}
Use mock mode for:
- Local development and iteration
- Testing pipeline structure and routing logic
- CI without API keys
Live adapter (OpenAI)
Live mode routes LLM-backed tasks to the OpenAI Responses API and activates typed tool integrations.
export OPENAI_API_KEY="sk-..."
cargo run -- examples/blog.agent blog_post \
--adapter openai \
--input '{"topic":"agent memory patterns"}'
Anthropic adapter (Claude)
Anthropic mode routes LLM-backed tasks to the Anthropic Messages API using Claude models. It supports the same tool calling and tracing features as the OpenAI live adapter.
export ANTHROPIC_API_KEY="sk-ant-..."
cargo run -- examples/blog.agent blog_post \
--adapter anthropic \
--input '{"topic":"agent memory patterns"}'
Model mapping
When using --adapter anthropic, OpenAI model names in .agent files are automatically mapped to Claude equivalents:
| OpenAI model | Claude model |
|---|---|
gpt-4.1 | claude-sonnet-4-20250514 |
gpt-4.1-mini | claude-haiku-4-5-20251001 |
gpt-4o | claude-sonnet-4-20250514 |
gpt-4o-mini | claude-haiku-4-5-20251001 |
If the model name already starts with claude-, it is passed through unchanged. This means you can write .agent files that work with both backends without modification, or use Claude model names directly when targeting Anthropic specifically.
Task behavior by adapter
| Task | Mock | Live (OpenAI) | Anthropic (Claude) |
|---|---|---|---|
research | Placeholder notes | OpenAI call, optionally enriched with web search | Claude call, optionally enriched with web search |
draft | Placeholder article | OpenAI call | Claude call |
compare | Placeholder decision | OpenAI call | Claude call |
respond | Placeholder reply | OpenAI call | Claude call |
llm_complete | Echoes prompt | OpenAI call | Claude call |
extract_intent | Fixed deterministic output | Fixed deterministic output | Fixed deterministic output |
route | Fixed deterministic output | Fixed deterministic output | Fixed deterministic output |
flaky_fetch | Fails N times then succeeds | Fails N times then succeeds | Fails N times then succeeds |
task ... by agent {} | Deterministic placeholder object | OpenAI call with schema-constrained JSON output | Claude call with schema-constrained JSON output |
extract_intent, route, and flaky_fetch behave identically in all modes.
Web search tool
When an agent declares tools: [web_search], live and anthropic mode tasks expose that declared tool through the runtime tool registry. The model can call web_search during task execution.
tool web_search(query: String) -> List[Obj{title: String, url: String, snippet: String}] {}
agent planner {
model: "gpt-4.1"
, tools: [web_search]
}
The native built-in currently returns at most five results per call. Applications that need different search behavior can supply their own ToolRegistry handler.
In mock mode, web_search in the tools list is parsed and stored but has no effect.
Fetch URL tool
When an agent declares tools: [fetch_url], live and anthropic mode tasks can request raw page text for a URL through the runtime tool registry.
tool fetch_url(url: String) -> Obj{content: String} {}
In mock mode, fetch_url returns a deterministic placeholder string instead of performing a network request.
Agent tasks and tool calling
Agent tasks are declared with by agent:
task investigate(topic: String) -> Obj{summary: String, sources: List[String]} by agent {}
In live and anthropic mode, the runtime:
- Selects the model from the bound
agent(with automatic model mapping in anthropic mode) - Exposes the agent's declared tools to the model
- Executes tool calls through the runtime tool registry
- Requires the final model output to decode as JSON matching the declared return type
This keeps orchestration in the DSL while provider-specific tool mechanics stay in the adapter/runtime layer.
JSON extraction
Claude models sometimes wrap JSON output in markdown code fences or add conversational text around the JSON object. The runtime handles this automatically by:
- Stripping markdown fences (
```json ... ```or``` ... ```) - Attempting direct JSON parse
- If that fails, extracting the first
{to last}substring and parsing that
This means agent tasks work reliably with both OpenAI and Claude without any changes to your .agent files.
Live tracing
Use --trace-live to print live execution trace lines to stderr. This works with both --adapter openai and --adapter anthropic.
cargo run -- examples/incident_runbook.agent respond_to_incident \
--adapter openai \
--trace-live \
--input '{"incident":"database failover drill"}'
Example trace lines (OpenAI):
[trace] task=draft_response_plan agent=researcher model=gpt-4.1 start args={"incident":"database failover drill"}
[trace] task=review_response_plan agent=reviewer tool=web_search call args={"query":"best practices for database failover drills site:aws.amazon.com"}
[trace] task=publish_runbook agent=commander model=gpt-4.1-mini result={"runbook":"Runbook for Database Failover Drill: ..."}
Example trace lines (Anthropic):
[trace] task=draft_response_plan agent=researcher model=claude-sonnet-4-20250514 start args={"incident":"database failover drill"}
[trace] task=draft_response_plan agent=researcher anthropic request mode=complete model=claude-sonnet-4-20250514
[trace] task=draft_response_plan agent=researcher anthropic response mode=complete text=...
Model resolution
The model used for a task is determined by:
AGL_OPENAI_MODELorAGL_ANTHROPIC_MODEL, when set for the selected provider.- The model declared by the agent bound with
by agent_name. - The native provider default when the declaration omits a model.
Legacy model declarations are mapped by provider and capability tier. Deployment overrides allow model upgrades without editing .agent source.
Environment variables
| Variable | Default | Description |
|---|---|---|
OPENAI_API_KEY | — | Required for --adapter openai (live is a compatibility alias) |
ANTHROPIC_API_KEY | — | Required for --adapter anthropic |
AGL_OPENAI_MODEL | gpt-5.6-sol | Global OpenAI model override |
AGL_ANTHROPIC_MODEL | provider default | Global Anthropic model override |
Error handling
Adapter errors are caught and surfaced as runtime failures:
Execution error: OPENAI_API_KEY is required when adapter mode is 'live'.
Execution error: ANTHROPIC_API_KEY is required when adapter mode is 'anthropic'.
Execution error: LLM call failed: <http error>
Execution error: <adapter timeout message>
Execution error: Task 'draft_response_plan' by agent 'researcher' failed after 1 attempts. Last error: RuntimeError: LLM call failed: ...
The --adapter openai or --adapter anthropic flag enables the respective adapter. --adapter live remains an alias for OpenAI compatibility.
Warning
Security
Never hardcode
OPENAI_API_KEYorANTHROPIC_API_KEYin.agentfiles, source code, or documentation. Use environment variables or your shell profile.
Compiler and runtime
The Rust implementation is normative for AGL 0.6. The versioned language contracts are in spec/agl-0.2.md through spec/agl-0.6.md. Rust is the sole language implementation.
Execution pipeline
AGL source
│
├─ lexer and parser ──> AST with source spans
├─ module loader ─────> imported program + cached public interfaces
├─ checker ───────────> types, effects, deployment requirements
├─ analyzer ──────────> warnings and suggestions
└─ runtime ───────────> deterministic or live execution + trace events
The CLI performs these phases in order. --check, --format, --lower, --effects, --summary, --docs, and --api stop after their respective inspection phase.
Public Rust API
The agl crate exposes compiler phases without shelling out:
use agl::{check_program, execute_pipeline, parse_program};
use std::collections::BTreeMap;
let program = parse_program(source)?;
check_program(&program)?;
let value = execute_pipeline(&program, "pipeline_name", BTreeMap::new(), ®istry, &context)?;
Important public types include Program, Registry, Invocation, TaskOutput, HandlerFailure, ExecutionContext, CancellationToken, EventStore, and the extension traits in agl::extension.
Handlers and invocations
Task declarations provide signatures; behavior comes from a Rust Registry or a native provider adapter. Contextual handlers receive an Invocation containing:
- stable execution, invocation, and idempotency identifiers;
- attempt number and optional execution context;
- a cooperative
CancellationToken; - usage reporting through
TaskOutput.
Handlers should check cancellation at meaningful points and return HandlerFailure with an explicit kind and retryability rather than throwing an untyped exception.
Retries, timeouts, and effects
Retries are bounded by the source declaration and resource budgets. Backoff supports initial delay, maximum delay, multiplier, and deterministic jitter. A retry preserves the logical invocation identity and idempotency key while incrementing the attempt number. A timeout cancels the invocation cooperatively and is non-retryable unless the program explicitly models another recovery path.
The checker infers transitive effects from task, tool, pipeline, and agent declarations. --effects exposes the result; --summary additionally reports external writes and approval boundaries. Retry safety is checked against declared idempotency and effects.
Structured concurrency
AGL 0.5/0.6 provides bounded parallel, ordered parallel map, and race statements. Child scopes are joined before their parent continues. race cancels losing branches. Scheduler groups, concurrency limits, rate limits, and pipeline budgets are enforced by the runtime.
Durable execution
ExecutionContext::durable and SqliteEventStore persist task attempts, results, checkpoints, approvals, and trace events. The CLI equivalent is:
cargo run -- examples/showcase_all_features.agent produce \
--event-store run.sqlite --execution-id incident-42
cargo run -- examples/showcase_all_features.agent produce \
--event-store run.sqlite --execution-id incident-42 --resume
Replay requires the same source/deployment contract. The runtime records content fingerprints and stable IDs so a resumed run cannot silently consume incompatible results.
Observability and redaction
ExecutionContext::events() returns structured TraceEvent values. The CLI writes them with --output-trace. Durable persistence and trace output redact configured secret values before storage. Event records include execution, invocation, attempt, idempotency, provider, usage, and failure information where available.
Diagnostics
Lexing, parsing, checking, and analysis failures carry stable codes and source spans. The CLI renders a human-readable diagnostic; the JSON-lines protocol and LSP expose machine-readable diagnostics for editor/build integration.
Examples
AgentLang ships with nineteen .agent examples in examples/. Each one exercises a distinct set of language features. The examples are executable source files; use cargo run -- <file> --check to validate one without running it.
blog.agent — sequential pipeline
Features: sequential let statements, two agents, field access
tool web_search(query: String) -> List[Obj{title: String, url: String, snippet: String}] {}
agent planner {
model: "gpt-4.1"
, tools: [web_search]
}
agent writer {
model: "gpt-4.1-mini"
, tools: []
}
task research(topic: String) -> Obj{notes: String} {}
task draft(notes: String) -> Obj{article: String} {}
pipeline blog_post(topic: String) -> String {
let r = run research with { topic: topic } by planner;
let d = run draft with { notes: r.notes } by writer;
return d.article;
}
cargo run -- examples/blog.agent blog_post \
--input '{"topic":"agent memory patterns"}'
{
"result": "[writer] Draft article:\n[planner] key points for 'agent memory patterns'"
}
compare.agent — parallel execution
Features: parallel { } join, merging branch outputs downstream
tool web_search(query: String) -> List[Obj{title: String, url: String, snippet: String}] {}
agent planner {
model: "gpt-4.1"
, tools: [web_search]
}
agent reviewer {
model: "gpt-4.1-mini"
, tools: []
}
task research(topic: String) -> Obj{notes: String} {}
task compare(note_a: String, note_b: String) -> Obj{decision: String} {}
pipeline compare_options(query: String) -> String {
parallel {
let a = run research with { topic: query + " option A" } by planner;
let b = run research with { topic: query + " option B" } by planner;
} join;
let c = run compare with { note_a: a.notes, note_b: b.notes } by reviewer;
return c.decision;
}
cargo run -- examples/compare.agent compare_options \
--input '{"query":"vector database"}'
{
"result": "[reviewer] Option A vs B\nA: [planner] key points for 'vector database option A'\nB: [planner] key points for 'vector database option B'"
}
support.agent — multi-step routing
Features: three sequential tasks, field threading between steps
agent triage {
model: "gpt-4.1-mini"
, tools: []
}
task extract_intent(message: String) -> Obj{intent: String, urgency: String} {}
task route(intent: String, urgency: String) -> Obj{queue: String} {}
task respond(intent: String, queue: String) -> Obj{reply: String} {}
pipeline support_reply(message: String) -> String {
let i = run extract_intent with { message: message } by triage;
let q = run route with { intent: i.intent, urgency: i.urgency } by triage;
let r = run respond with { intent: i.intent, queue: q.queue } by triage;
return r.reply;
}
cargo run -- examples/support.agent support_reply \
--input '{"message":"urgent refund request"}'
{
"result": "[triage] Routed as billing to billing-priority."
}
reliability.agent — retry, fallback, conditional
Features: retries, on_fail use, if/else, string equality
agent ops {
model: "gpt-4.1-mini"
, tools: []
}
task flaky_fetch(key: String, failures_before_success: Number) -> Obj{data: String} {}
task draft(notes: String) -> Obj{article: String} {}
pipeline resilient_brief(topic: String, fail_count: Number) -> String {
let f = run flaky_fetch
with { key: topic, failures_before_success: fail_count }
by ops
retries 2
on_fail use { data: "fallback for " + topic };
if f.data == "fallback for " + topic {
let d = run draft with { notes: "Fallback path: " + f.data } by ops;
return d.article;
} else {
let d = run draft with { notes: "Fresh path: " + f.data } by ops;
return d.article;
}
}
Run — succeeds within retry budget (fail_count: 1 < retries 2):
cargo run -- examples/reliability.agent resilient_brief \
--input '{"topic":"api-status","fail_count":1}'
{
"result": "[ops] Draft article:\nFresh path: [ops] fetched payload for api-status"
}
Run — exhausts retries, uses fallback (fail_count: 5 > retries 2):
cargo run -- examples/reliability.agent resilient_brief \
--input '{"topic":"api-status","fail_count":5}'
{
"result": "[ops] Draft article:\nFallback path: fallback for api-status"
}
live_answer.agent — minimal live adapter test
Features: single-task pipeline, direct LLM prompt, llm_complete
agent assistant {
model: "gpt-4.1-mini"
, tools: []
}
task llm_complete(prompt: String) -> Obj{text: String} {}
pipeline answer(question: String) -> String {
let r = run llm_complete
with { prompt: "Answer briefly: " + question }
by assistant;
return r.text;
}
Mock mode:
cargo run -- examples/live_answer.agent answer \
--input '{"question":"What is an agentic workflow?"}'
{
"result": "[assistant] Answer briefly: What is an agentic workflow?"
}
Live mode:
export OPENAI_API_KEY="sk-..."
cargo run -- examples/live_answer.agent answer \
--adapter live \
--input '{"question":"What is an agentic workflow?"}'
complete_agent.agent — fuller multi-agent workflow
Features: tool-enabled research agent, parallel research, downstream comparison, direct LLM prompt, final drafting step
tool web_search(query: String) -> List[Obj{title: String, url: String, snippet: String}] {}
agent scout {
model: "gpt-4.1"
, tools: [web_search]
}
agent analyst {
model: "gpt-4.1-mini"
, tools: []
}
agent writer {
model: "gpt-4.1-mini"
, tools: []
}
task research(topic: String) -> Obj{notes: String} {}
task compare(note_a: String, note_b: String) -> Obj{decision: String} {}
task llm_complete(prompt: String) -> Obj{text: String} {}
task draft(notes: String) -> Obj{article: String} {}
pipeline executive_brief(product: String, competitor_a: String, competitor_b: String) -> String {
parallel {
let a = run research with { topic: competitor_a + " strategy for " + product } by scout;
let b = run research with { topic: competitor_b + " strategy for " + product } by scout;
} join;
let c = run compare with { note_a: a.notes, note_b: b.notes } by analyst;
let o = run llm_complete with { prompt: "Write a concise executive brief outline for " + product + ".\nDecision context:\n" + c.decision } by analyst;
let d = run draft with { notes: "Executive outline:\n" + o.text + "\n\nResearch A:\n" + a.notes + "\n\nResearch B:\n" + b.notes } by writer;
return d.article;
}
Mock mode:
cargo run -- examples/complete_agent.agent executive_brief \
--input '{"product":"team chat","competitor_a":"Slack","competitor_b":"Microsoft Teams"}'
This example is the closest thing in-tree to a "complete agent" workflow: a tool-enabled scout gathers context, an analyst synthesizes a decision, and a writer turns that into a final artifact.
tool_declarations.agent — first-class tool declarations
Features: declared tool, agent tool reference validation
tool web_search(query: String) -> List[Obj{title: String, url: String, snippet: String}] {}
agent researcher {
model: "gpt-4.1-mini"
, tools: [web_search]
}
pipeline declared_tool_name(query: String) -> String {
return "declared " + query;
}
cargo run -- examples/tool_declarations.agent declared_tool_name \
--input '{"query":"web_search"}'
{
"result": "declared web_search"
}
tool_backed_research.agent — tool-backed research task
Features: declared tool, agent capability binding, tool-aware research task
tool web_search(query: String) -> List[Obj{title: String, url: String, snippet: String}] {}
agent scout {
model: "gpt-4.1"
, tools: [web_search]
}
task research(topic: String) -> Obj{notes: String} {}
pipeline notes(topic: String) -> String {
let r = run research with { topic: topic } by scout;
return r.notes;
}
cargo run -- examples/tool_backed_research.agent notes \
--input '{"topic":"incident response"}'
{
"result": "[scout] key points for 'incident response'"
}
Live mode uses the declared web_search tool when the model decides it needs external grounding:
cargo run -- examples/tool_backed_research.agent notes \
--adapter live \
--input '{"topic":"incident response"}'
agent_task.agent — task declared for agent execution
Features: task ... by agent {}, explicit agent binding, declared tool access
tool web_search(query: String) -> List[Obj{title: String, url: String, snippet: String}] {}
agent researcher {
model: "gpt-4.1"
, tools: [web_search]
}
task investigate(topic: String) -> Obj{summary: String, sources: List[String]} by agent {}
pipeline brief(topic: String) -> String {
let r = run investigate with { topic: topic } by researcher;
return r.summary;
}
Mock mode:
cargo run -- examples/agent_task.agent brief \
--input '{"topic":"incident response"}'
{
"result": "[researcher:investigate.summary] incident response"
}
Live mode:
cargo run -- examples/agent_task.agent brief \
--adapter live \
--input '{"topic":"incident response"}'
multiagent_blog.agent — declarative workflow with internal review looping
Features: workflow, stage, review, inferred review task, internal planner-reviewer revision loop, editor and publisher handoff
tool web_search(query: String) -> List[Obj{title: String, url: String, snippet: String}] {}
tool fetch_url(url: String) -> Obj{content: String} {}
agent planner {
model: "gpt-4.1"
, tools: [web_search, fetch_url]
}
agent reviewer {
model: "gpt-4.1-mini"
, tools: [web_search]
}
agent editor {
model: "gpt-4.1-mini"
, tools: []
}
agent publisher {
model: "gpt-4.1-mini"
, tools: []
}
task plan_blog(topic: String) -> Obj{outline: String, sources: List[String]} by agent {}
task review_outline(topic: String, outline: String, sources: List[String]) -> Obj{approved: Bool, feedback: String} by agent {}
task revise_outline(topic: String, outline: String, sources: List[String], feedback: String) -> Obj{outline: String, sources: List[String]} by agent {}
task write_blog(topic: String, outline: String) -> Obj{article: String} by agent {}
task edit_blog(topic: String, article: String) -> Obj{title: String, article: String} by agent {}
task publish_blog(topic: String, title: String, article: String) -> Obj{post: String} by agent {}
workflow publish_topic_blog(topic: String) -> String {
stage plan = planner does plan_blog(topic);
review outline = reviewer checks plan revise with planner using revise_outline max_rounds 3;
stage draft = planner does write_blog(topic, outline.outline);
stage edited = editor does edit_blog(topic, draft.article);
stage published = publisher does publish_blog(topic, edited.title, edited.article);
return published.post;
}
This example is the higher-level authoring model: the user declares stages and a review policy, and the compiler lowers that to an explicit pipeline with run, while, and break internally. The planner produces an outline, the reviewer approves or rejects it, and the revise/re-review loop happens in the lowered IR instead of the source file.
cargo run -- examples/multiagent_blog.agent publish_topic_blog \
--input '{"topic":"agent memory systems"}'
cargo run -- examples/multiagent_blog.agent publish_topic_blog \
--lower
incident_runbook.agent — declarative incident workflow
Features: workflow, stage, review, tool-backed planning, revision budget, final publishing handoff
tool web_search(query: String) -> List[Obj{title: String, url: String, snippet: String}] {}
tool fetch_url(url: String) -> Obj{content: String} {}
agent researcher {
model: "gpt-4.1"
, tools: [web_search, fetch_url]
}
agent reviewer {
model: "gpt-4.1-mini"
, tools: [web_search]
}
agent commander {
model: "gpt-4.1-mini"
, tools: []
}
task draft_response_plan(incident: String) -> Obj{plan: String, sources: List[String]} by agent {}
task review_response_plan(incident: String, plan: String, sources: List[String]) -> Obj{approved: Bool, feedback: String} by agent {}
task revise_response_plan(incident: String, plan: String, sources: List[String], feedback: String) -> Obj{plan: String, sources: List[String]} by agent {}
task publish_runbook(incident: String, plan: String) -> Obj{runbook: String} by agent {}
workflow respond_to_incident(incident: String) -> String {
stage draft_plan = researcher does draft_response_plan(incident);
review response_plan = reviewer checks draft_plan revise with researcher using revise_response_plan max_rounds 2;
stage published = commander does publish_runbook(incident, response_plan.plan);
return published.runbook;
}
cargo run -- examples/incident_runbook.agent respond_to_incident \
--input '{"incident":"database failover drill"}'
cargo run -- examples/incident_runbook.agent respond_to_incident \
--lower
launch_dossier.agent — full multi-agent workflow
Features: workflow, multiple agents, tool-backed research, review/revise loop, strategic synthesis, editing, publishing
tool web_search(query: String) -> List[Obj{title: String, url: String, snippet: String}] {}
tool fetch_url(url: String) -> Obj{content: String} {}
agent researcher {
model: "gpt-4.1"
, tools: [web_search, fetch_url]
}
agent reviewer {
model: "gpt-4.1-mini"
, tools: [web_search]
}
agent strategist {
model: "gpt-4.1-mini"
, tools: []
}
agent editor {
model: "gpt-4.1-mini"
, tools: []
}
agent publisher {
model: "gpt-4.1-mini"
, tools: []
}
task draft_market_scan(product: String, audience: String) -> Obj{scan: String, sources: List[String]} by agent {}
task review_approved_scan(product: String, audience: String, scan: String, sources: List[String]) -> Obj{approved: Bool, feedback: String} by agent {}
task revise_market_scan(product: String, audience: String, scan: String, sources: List[String], feedback: String) -> Obj{scan: String, sources: List[String]} by agent {}
task build_positioning(product: String, audience: String, scan: String) -> Obj{positioning: String} by agent {}
task draft_launch_plan(product: String, audience: String, positioning: String) -> Obj{plan: String} by agent {}
task edit_launch_plan(product: String, plan: String) -> Obj{title: String, plan: String} by agent {}
task publish_launch_dossier(product: String, audience: String, title: String, plan: String) -> Obj{dossier: String} by agent {}
workflow launch_dossier(product: String, audience: String) -> String {
stage market_scan = researcher does draft_market_scan(product, audience);
review approved_scan = reviewer checks market_scan revise with researcher using revise_market_scan max_rounds 2;
stage positioning = strategist does build_positioning(product, audience, approved_scan.scan);
stage launch_plan = strategist does draft_launch_plan(product, audience, positioning.positioning);
stage edited = editor does edit_launch_plan(product, launch_plan.plan);
stage published = publisher does publish_launch_dossier(product, audience, edited.title, edited.plan);
return published.dossier;
}
cargo run -- examples/launch_dossier.agent launch_dossier \
--input '{"product":"AI support copilot","audience":"IT operations teams"}'
cargo run -- examples/launch_dossier.agent launch_dossier \
--lower
This is the most complete high-level example in-tree: a researcher gathers and revises market context, a strategist turns that into positioning and a launch plan, then an editor and publisher finalize the dossier.
while_loop.agent — first-class looping construct
Features: while, variable rebinding, deterministic loop progress
agent ops {
model: "gpt-4.1-mini"
, tools: []
}
task countdown(current: Number) -> Obj{next: Number, done: Bool} {}
pipeline loop_to_zero(start: Number) -> Number {
let state = run countdown with { current: start } by ops;
while state.done == false {
let state = run countdown with { current: state.next } by ops;
}
return state.next;
}
cargo run -- examples/while_loop.agent loop_to_zero \
--input '{"start":3}'
{
"result": 0
}
break_continue.agent — loop control flow
Features: while, break, continue, variable rebinding
cargo run -- examples/break_continue.agent stop_early \
--input '{"start":4}'
cargo run -- examples/break_continue.agent skip_once \
--input '{"start":4}'
{
"result": 2
}
{
"result": 0
}
showcase_all_features.agent — comprehensive feature showcase
Features: type aliases, enums, shorthand syntax, max_concurrency, try/catch, pipeline-calls-pipeline, assert, test blocks, native task handlers, observability
This example exercises every new AgentLang construct in a content production system that researches a topic, drafts in parallel across two angles, merges drafts, runs a quality review loop, and produces a final deliverable.
-- type aliases and enums
enum ContentTone { formal, conversational, technical };
type ResearchNotes = Obj{notes: String, sources: List[String]};
type DraftResult = Obj{article: String, word_count: Number};
-- agent with optional model
agent writer { tools: [] }
-- sub-pipeline (pipeline-calls-pipeline)
pipeline research_and_draft(topic: String, angle: String) -> DraftResult {
let notes = run research with { topic: topic + " — " + angle } by researcher retries 1 timeout 30;
let article = draft(notes.notes) by writer; -- shorthand syntax
return article;
}
-- main pipeline with max_concurrency, try/catch, assert
pipeline produce(topic: String) -> String {
parallel max_concurrency 2 {
let angle_a = run research_and_draft with { topic: topic, angle: "technical deep-dive" };
let angle_b = run research_and_draft with { topic: topic, angle: "practical applications" };
} join;
try {
let enrichment = run risky_enrich with { topic: topic };
} catch err {
let fallback = run fallback_enrich with { query: err };
}
assert final.title != "", "Title must not be empty";
return final.article;
}
-- in-language tests
test "merge_drafts combines two drafts" {
let result = run merge_drafts with { draft_a: "A.", draft_b: "B.", word_count_a: 100, word_count_b: 200 };
assert result.total_words == 300, "Total words should be sum of both";
}
# Run the pipeline with native handlers
cargo run -- examples/showcase_all_features.agent produce \
--input '{"topic":"AI safety"}'
# Run tests
cargo run -- examples/showcase_all_features.agent --test
# With execution trace
cargo run -- examples/showcase_all_features.agent produce \
--input '{"topic":"AI safety"}' \
--output-trace trace.json
tax_advisory.agent — complex multi-agent tax advisory
Features: enums (FilingStatus), type aliases (TaxProfile, RulesSummary, Strategy, ReviewVerdict), parallel with max_concurrency, retry with fallback, timeout, multi-agent review loop
A taxpayer submits financial details and the system produces a personalised tax-optimisation report through classification, parallel research, strategy building, compliance review, and formatting.
cargo run -- examples/tax_advisory.agent tax_optimisation_report \
--input '{"filing_status":"single","income":120000,"state":"California","has_business":true}'
Additional 0.6 examples
The repository also includes these focused programs:
| File | Focus |
|---|---|
deployment_requirements.agent | Provider-neutral deployment requirements |
newsletter.agent | Workflow authoring and lowering |
retry_abort.agent | Explicit retry exhaustion and abort behavior |
native_embed.rs | Embedding the Rust library and registering a handler |
Validate every .agent file in a checkout with:
for file in examples/*.agent; do cargo run -- "$file" --check || exit 1; done
Authoring tips
- Keep task signatures small and explicit.
- Choose the pipeline return type intentionally — it's what
--inputvalidation checks against. - Include both a happy-path and a failure-path input when testing retry behavior.
- Add a new example whenever you introduce a new language feature.
Rust implementation status
AGL 0.6 is implemented by the native Rust compiler, checker, runtime, CLI, adapters, and embedding API. Rust is the sole language implementation.
Implemented surface
| Area | Status |
|---|---|
| Lexer, parser, source spans, diagnostics | Complete |
| AGL 0.2–0.6 language versions | Complete |
Types, records, unions, Result, exhaustive matching | Complete |
| Workflows and pipeline lowering | Complete |
| Effects, idempotency, retries, budgets | Complete |
| Provider-neutral deployment bindings and policy | Complete |
Parallel, parallel map, race, cancellation | Complete |
| Durable SQLite-backed events, replay, approvals | Complete |
| Evaluations, distributions, baselines, replay | Complete |
| Modules, visibility, package locks, API comparison | Complete |
| Formatter, JSON-lines protocol, LSP, completions | Complete |
| OpenAI and Anthropic adapters plus native tools | Complete |
| Rust handler/tool/adapter extension traits | Complete |
Extension boundary
Rust accepts the checked-in .agent examples and is the implementation used by the conformance and regression suites. Applications extend it through Registry, ToolRegistry, and the extension traits in agl::extension.
Embedding
use agl::{check_program, execute_pipeline, parse_program, Registry};
use agl::context::ExecutionContext;
use std::collections::BTreeMap;
let program = parse_program(source)?;
check_program(&program)?;
let registry = Registry::default();
let value = execute_pipeline(
&program,
"pipeline_name",
BTreeMap::new(),
®istry,
&ExecutionContext::default(),
)?;
Validation
cargo fmt --all -- --check
cargo clippy --all-targets --all-features --offline -- -D warnings
cargo test --locked --offline
cargo package --locked --offline
The live-provider tests are intentionally ignored in ordinary runs because they require credentials and make billable network calls. Run them manually or through .github/workflows/live-smoke.yml before a provider release.
Native extensions
Rust applications extend AGL by depending on the agl crate and registering
handlers at startup:
let mut registry = agl::Registry::default();
registry.register("uppercase", |args, _agent| {
let text = args["text"].as_str().unwrap_or_default();
Ok(serde_json::json!({"text": text.to_uppercase()}))
});
Run the complete embedding example with:
cargo run --example native_embed
Register tasks on Registry and model-callable tools on ToolRegistry. Pass
custom tools to registry_for_with_tools before running pipelines with a native
provider adapter. Contextual task handlers receive an Invocation and return
TaskOutput or HandlerFailure.
The versioned traits in agl::extension cover task handlers, tools, model
adapters, hosts, policies, and graders. Validate extension descriptors against
EXTENSION_API_VERSION before registration.
Custom handlers are compiled into the host application. AGL does not load Rust dynamic libraries or external handler modules. Native extensions share the host process and permissions.
For removed host APIs and migration steps, see the Rust-only migration notes.
Releasing the Rust implementation
Required checks
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --locked
mdbook build
cargo bench --bench runtime
cargo package --locked
The manually triggered Live Provider Smoke GitHub workflow must pass once for OpenAI and once for Anthropic before a production release. Configure repository secrets OPENAI_API_KEY and ANTHROPIC_API_KEY; optional repository variables AGL_OPENAI_MODEL and AGL_ANTHROPIC_MODEL select models without code changes.
Release sequence
- Update
CHANGELOG.mdand confirm the version inCargo.toml. - Run the required checks and both live-provider workflows.
- Merge the reviewed branch into
main. - Create an annotated tag matching the version in
Cargo.tomlfrom the merge commit. - Run
cargo publish --dry-run, then publish only with explicit maintainer approval. - Create the GitHub release from the tag using the matching changelog section.
Publishing and tagging are intentionally manual external actions.
Formal Semantics
This page is a historical overview of AgentLang v0 with sketches of later extensions. It is not a complete formal specification of AGL 0.6. The sequential core semantics makes control-flow completions and scope restoration explicit and identifies the remaining proof obligations.
1. Abstract Syntax
A program is a tuple of agent definitions, tool signatures, task signatures, pipeline definitions, type aliases, enum definitions, and test blocks:
\[P ::= \langle A, U, T, W, \mathcal{Y}, \mathcal{N}, \mathcal{B} \rangle\]
where \(A\) is the set of agent definitions, \(U\) the set of tool signatures, \(T\) the set of task signatures, \(W\) the set of pipeline definitions, \(\mathcal{Y}\) the set of type alias definitions, \(\mathcal{N}\) the set of enum definitions, and \(\mathcal{B}\) the set of test blocks.
A tool signature declares typed inputs and a typed output for runtime-executable tools:
\[u : (x_1{:}\tau_1,\ \dots,\ x_n{:}\tau_n) \to \tau_o\]
A task signature declares its input parameters and return type:
\[t : (x_1{:}\tau_1,\ \dots,\ x_n{:}\tau_n) \to \tau_o\]
Agent tasks add an execution marker:
\[t^{agent} : (x_1{:}\tau_1,\ \dots,\ x_n{:}\tau_n) \to \tau_o\]
Pipeline statement forms:
\[\begin{align*} s\ ::=\ & \texttt{let}\ x = \texttt{run}\ t\ \texttt{with}\ \{k_i{:}e_i\}\ [\texttt{by}\ a]\ [\texttt{retries}\ n]\ [\texttt{on\_fail abort}] \\ \mid\ & \texttt{let}\ x = \texttt{run}\ t\ \texttt{with}\ \{k_i{:}e_i\}\ [\texttt{by}\ a]\ [\texttt{retries}\ n]\ \texttt{on\_fail use}\ e_f \\ \mid\ & \texttt{parallel}\ [\texttt{max\_concurrency}\ n]\ \{r_1;\dots;r_m\}\ \texttt{join} \\ \mid\ & \texttt{if}\ e\ \{s^*\}\ [\texttt{else}\ \{s^*\}] \\ \mid\ & \texttt{while}\ e\ \{s^*\} \\ \mid\ & \texttt{break} \\ \mid\ & \texttt{continue} \\ \mid\ & \texttt{if let}\ x = e\ \{s^*\}\ [\texttt{else}\ \{s^*\}] \\ \mid\ & \texttt{try}\ \{s^*\}\ \texttt{catch}\ x\ \{s^*\} \\ \mid\ & \texttt{assert}\ e,\ c \\ \mid\ & \texttt{return}\ e \end{align*}\]
Each \(r_i\) inside a parallel block is restricted to the run statement form (let x = run ...). General statements (if, return, nested parallel) are not permitted inside parallel.
Test block form:
\[b\ ::=\ \texttt{test}\ c\ \{s^*\}\]
Test blocks are top-level declarations that run only under --test. Each has its own scope.
Expression forms:
Variable references and field access chains are unified into a single form \(x.f_1{\cdots}f_n\) where \(n \geq 0\) (\(n = 0\) is a plain variable reference, \(n \geq 1\) is a field access chain of depth \(n\)):
\[\begin{align*} e\ ::=\ & c \mid \texttt{null} \mid x.f_1{\cdots}f_n \mid (e) \mid \{k_i{:}e_i\} \mid [e^*] \\ \mid\ & e + e \mid e\ {==}\ e \mid e\ {!=}\ e \end{align*}\]
Types:
\[\tau\ ::=\ \texttt{String} \mid \texttt{Number} \mid \texttt{Bool} \mid \texttt{List}[\tau] \mid \texttt{Option}[\tau] \mid \texttt{Obj}\{f_i{:}\tau_i\} \mid \texttt{Enum}[n]\]
where \(\texttt{Enum}[n]\) denotes an enum type with \(n\) declared variants. Enum values are assignable to \(\texttt{String}\). Type aliases are resolved at parse time and do not appear in the type grammar.
2. Static Semantics
Environments
| Symbol | Definition |
|---|---|
| \(\Gamma : \text{Var} \to \tau\) | Typing environment — maps variable names to their types |
| \(\Upsilon : \text{ToolName} \to (\vec{\tau}_{in}, \tau_{out})\) | Tool table — maps tool names to their signatures |
| \(\Sigma : \text{TaskName} \to (\vec{\tau}_{in}, \tau_{out})\) | Task table — maps task names to their signatures |
| \(\Delta : \text{AgentName} \to \text{AgentSpec}\) | Agent table — maps agent names to their specs |
Expression typing
\[\dfrac{}{\Gamma \vdash c : \text{type}(c)} \quad \text{(literal)}\]
\[\dfrac{x \in \text{dom}(\Gamma)}{\Gamma \vdash x : \Gamma(x)} \quad \text{(variable)}\]
Field access chains are typed by iterating the one-step rule:
\[\dfrac{\Gamma \vdash e : \texttt{Obj}\{f_i{:}\tau_i\} \qquad f_j \in \{f_i\}}{\Gamma \vdash e.f_j : \tau_j} \quad \text{(field access)}\]
For a chain \(x.f_1{\cdots}f_n\), this rule is applied \(n\) times in sequence starting from \(\Gamma \vdash x : \Gamma(x)\).
\[\dfrac{\Gamma \vdash e_1 : \tau \quad \Gamma \vdash e_2 : \tau \quad \tau \in \{\texttt{String},\ \texttt{Number}\}}{\Gamma \vdash e_1 + e_2 : \tau} \quad \text{(addition)}\]
\[\dfrac{\Gamma \vdash e_1 : \tau_1 \quad \Gamma \vdash e_2 : \tau_2 \quad \text{comparable}(\tau_1, \tau_2)}{\Gamma \vdash e_1\ {==}\ e_2 : \texttt{Bool}} \quad \text{(equality)}\]
\[\dfrac{\Gamma \vdash e_1 : \tau_1 \quad \Gamma \vdash e_2 : \tau_2 \quad \text{comparable}(\tau_1, \tau_2)}{\Gamma \vdash e_1\ {!=}\ e_2 : \texttt{Bool}} \quad \text{(inequality)}\]
where \(\text{comparable}(\tau_1, \tau_2)\) holds when \(\tau_1\) and \(\tau_2\) are mutually assignable, or when one is \(\texttt{Null}\) and the other is \(\texttt{Option}[\tau]\), or when one is \(\texttt{Enum}[E]\) and the other is \(\texttt{String}\).
\[\dfrac{\forall\, i\colon\ \Gamma \vdash e_i : \tau_i}{\Gamma \vdash \{k_i{:}e_i\} : \texttt{Obj}\{k_i{:}\tau_i\}} \quad \text{(object literal)}\]
\[\dfrac{\forall\, i\colon\ \Gamma \vdash e_i : \tau}{\Gamma \vdash [e_1,\dots,e_n] : \texttt{List}[\tau]} \quad \text{(list literal — all items same type)}\]
\[\dfrac{}{\Gamma \vdash \texttt{null} : \texttt{Null}} \quad \text{(null literal)}\]
Statement typing and completion environments
Statement checking separates normal completion, return, break, continue, and failure. See the static completion analysis for the implemented rules shared by all versions. A single post-statement environment is insufficient: returned branches do not feed the next statement, and a catch begins from the possible failure prefixes rather than the try entry.
Calls validate arguments and fallback assignability against their declared
signature, then bind the declared output type on normal completion. Agent tasks
require a declared by agent. Pipeline calls use fresh callee parameter scopes.
Assertions require Bool. Optional, match, and catch names are lexical bindings
restored before any outgoing environments are merged.
Loops preserve entry types on normal/continue back-edges and merge zero-iteration and break paths at exit. Break and continue are valid only in loop bodies.
Enum typing
\[\dfrac{ v \in \text{variants}(\mathcal{N}(E)) }{ \Gamma \vdash v : \texttt{Enum}[E] }\]
Enum literals are string constants validated against the declared variant set. A string literal that matches a variant is inferred as the corresponding Enum[E] type. Enum types are assignable to String (safe widening via the subtyping relation), but String is not assignable to Enum[E] — this ensures that only known-valid variants pass the static checker. Enum variant names must be globally unique across all enum declarations.
Subtyping
AgentLang uses a structural subtyping relation \(\tau_a <: \tau_e\) ("actual is assignable to expected"):
\[\dfrac{}{\tau <: \tau} \quad \text{(reflexivity)}\]
\[\dfrac{}{\texttt{Enum}[E] <: \texttt{String}} \quad \text{(enum widening)}\]
\[\dfrac{\tau_a <: \tau_e}{\texttt{List}[\tau_a] <: \texttt{List}[\tau_e]} \quad \text{(list covariance)}\]
\[\dfrac{\text{dom}(\sigma_e) \subseteq \text{dom}(\sigma_a) \qquad \forall\, f \in \text{dom}(\sigma_e)\colon\ \sigma_a(f) <: \sigma_e(f)}{\texttt{Obj}\{\sigma_a\} <: \texttt{Obj}\{\sigma_e\}} \quad \text{(width + depth subtyping)}\]
\[\dfrac{}{\texttt{Null} <: \texttt{Option}[\tau]} \quad \text{(null assignable to option)}\]
\[\dfrac{\tau_a <: \tau_e}{\texttt{Option}[\tau_a] <: \texttt{Option}[\tau_e]} \quad \text{(option covariance)}\]
Note on covariance: List and Obj subtyping are covariant. This is classically unsound for mutable containers, but AgentLang has no mutation operators (no assignment, no list append, no field update). The runtime's deepcopy at handler boundaries prevents handler-side mutation from leaking. If mutation is ever added, these rules must be revised.
Test block typing
\[\dfrac{ \Gamma_0 = \emptyset \qquad \Gamma_0 \vdash s^*\ \dashv \Gamma' }{ \vdash \texttt{test}\ c\ \{s^*\} : \checkmark }\]
Test blocks are checked in an empty initial environment (they have their own scope). They do not contribute to the program's pipeline environments.
Parallel typing
\[\dfrac{ \forall\, i \neq j\colon\ x_i \neq x_j \qquad \forall\, i\colon\ x_i \notin \text{dom}(\Gamma) \qquad \forall\, i\colon\ \Gamma \vdash \texttt{run}\ t_i\ \texttt{with}\ \vec{a}_i\ \cdots : \tau_i }{ \Gamma \vdash \texttt{parallel}\ \{\ \texttt{let}\ x_i = \texttt{run}\ t_i\ \vec{a}_i\ \}\ \texttt{join}\ \dashv\ \Gamma[x_1 \mapsto \tau_1,\ \dots,\ x_m \mapsto \tau_m] }\]
All targets must be fresh (not already in \(\Gamma\)) and pairwise distinct. This ensures the result is a true disjoint extension of \(\Gamma\).
Return typing and pipeline well-typedness
Every reachable return expression must be assignable to the pipeline's declared result type. Checking starts with the declared parameter types and propagates only normal completion to subsequent statements. All versions require a reachable return. AGL 0.2 permits normal fallthrough; later versions reject it. Unreachable statements still receive analysis warnings but do not contribute return types or environments.
3. Dynamic Semantics
Runtime configuration
A runtime configuration is a pair \(\langle S,\ E \rangle\) where:
- \(S\) is the remaining statement stream
- \(E\) is the environment mapping variable names to runtime values
Transition rules
Run (success):
\[\langle [\texttt{let}\ x = \texttt{run}\ t\ \vec{a}] \cdot S,\ E \rangle \;\longrightarrow\; \langle S,\ E[x \mapsto \text{handler}(t,\ \mathcal{A}\lbrack\!\lbrack\vec{a}\rbrack\!\rbrack_E)] \rangle\]
For agent tasks, the handler is synthesized by the runtime: the bound agent selects a model and tool set, the model may emit tool calls, tool calls are executed against the runtime tool registry, and the final model output is decoded into a runtime value that must match the declared task return type.
Retry: on failure, re-submits the run statement with the retry counter decremented. When the budget reaches zero, the failure policy applies.
Failure policy — abort:
\[\langle [\texttt{let}\ x = \texttt{run}\ t\ \vec{a}\ \texttt{on\_fail abort}] \cdot S,\ E \rangle \;\xrightarrow{\text{fail}}\; \textbf{Error}\]
Failure policy — use:
\[\langle [\texttt{let}\ x = \texttt{run}\ t\ \vec{a}\ \texttt{on\_fail use}\ e_f] \cdot S,\ E \rangle \;\xrightarrow{\text{fail}}\; \langle S,\ E[x \mapsto \mathcal{E}\lbrack\!\lbrack e_f \rbrack\!\rbrack_E] \rangle\]
Parallel join:
Each \(r_i \equiv \texttt{let}\ x_i = \texttt{run}\ t_i\ \vec{a}_i\). All branches execute concurrently against a snapshot of \(E\), producing values \(v_i = \text{handler}(t_i,\ \mathcal{A}\lbrack\!\lbrack\vec{a}_i\rbrack\!\rbrack_E)\):
\[\langle [\texttt{parallel}\{r_1;\dots;r_m\}\texttt{ join}] \cdot S,\ E \rangle \;\longrightarrow\; \langle S,\ E \cup \{x_1 \mapsto v_1,\ \dots,\ x_m \mapsto v_m\} \rangle\]
All \(x_i\) are distinct and not in \(\text{dom}(E)\) (enforced statically), so the extension is disjoint.
If (true branch, with else):
\[\langle [\texttt{if}\ e_c\ \{s_{then}^*\}\ \texttt{else}\ \{s_{else}^*\}] \cdot S,\ E \rangle \;\longrightarrow\; \langle s_{then}^* \cdot S,\ E \rangle \quad \text{when}\ \mathcal{E}\lbrack\!\lbrack e_c \rbrack\!\rbrack_E = \textit{true}\]
If (false branch, with else):
\[\langle [\texttt{if}\ e_c\ \{s_{then}^*\}\ \texttt{else}\ \{s_{else}^*\}] \cdot S,\ E \rangle \;\longrightarrow\; \langle s_{else}^* \cdot S,\ E \rangle \quad \text{when}\ \mathcal{E}\lbrack\!\lbrack e_c \rbrack\!\rbrack_E = \textit{false}\]
If (true branch, without else):
\[\langle [\texttt{if}\ e_c\ \{s_{then}^*\}] \cdot S,\ E \rangle \;\longrightarrow\; \langle s_{then}^* \cdot S,\ E \rangle \quad \text{when}\ \mathcal{E}\lbrack\!\lbrack e_c \rbrack\!\rbrack_E = \textit{true}\]
If (false branch, without else — skip):
\[\langle [\texttt{if}\ e_c\ \{s_{then}^*\}] \cdot S,\ E \rangle \;\longrightarrow\; \langle S,\ E \rangle \quad \text{when}\ \mathcal{E}\lbrack\!\lbrack e_c \rbrack\!\rbrack_E = \textit{false}\]
While:
\[\langle [\texttt{while}\ e_c\ \{s^*\}] \cdot S,\ E \rangle \;\longrightarrow\; \langle s^* \cdot [\texttt{while}\ e_c\ \{s^*\}] \cdot S,\ E \rangle \quad \text{when}\ \mathcal{E}\lbrack\!\lbrack e_c \rbrack\!\rbrack_E = \textit{true}\]
\[\langle [\texttt{while}\ e_c\ \{s^*\}] \cdot S,\ E \rangle \;\longrightarrow\; \langle S,\ E \rangle \quad \text{when}\ \mathcal{E}\lbrack\!\lbrack e_c \rbrack\!\rbrack_E = \textit{false}\]
Break:
\[\langle [\texttt{break}] \cdot S,\ E \rangle \;\longrightarrow\; \langle S_{post\text{-}loop},\ E \rangle\]
where \(S_{post\text{-}loop}\) is the statement stream after removing the enclosing while and its remaining body. Equivalently, break raises a control-flow signal that unwinds to the nearest enclosing loop, which then terminates.
Continue:
\[\langle [\texttt{continue}] \cdot S,\ E \rangle \;\longrightarrow\; \langle [\texttt{while}\ e_c\ \{s^*\}] \cdot S_{post\text{-}loop},\ E \rangle\]
continue raises a control-flow signal that skips the remaining statements in the current iteration and re-evaluates the loop condition.
If-let (some):
\[\langle [\texttt{if let}\ x = e_o\ \{s_{then}^*\}\ \texttt{else}\ \{s_{else}^*\}] \cdot S,\ E \rangle \;\longrightarrow\; \langle s_{then}^* \cdot S,\ E[x \mapsto v] \rangle \quad \text{when}\ \mathcal{E}\lbrack\!\lbrack e_o \rbrack\!\rbrack_E = v \neq \texttt{null}\]
The binding \(x\) is scoped to the then branch only. After the branch completes, \(x\) is removed from the environment (or restored to its prior value if it existed before the if let).
If-let (none):
\[\langle [\texttt{if let}\ x = e_o\ \{s_{then}^*\}\ \texttt{else}\ \{s_{else}^*\}] \cdot S,\ E \rangle \;\longrightarrow\; \langle s_{else}^* \cdot S,\ E \rangle \quad \text{when}\ \mathcal{E}\lbrack\!\lbrack e_o \rbrack\!\rbrack_E = \texttt{null}\]
When else is absent and the value is null, execution skips to the next statement.
Try/catch:
The try body propagates normal completion, return, break, and continue unchanged. Only an execution error enters the catch. The handler begins with the environment at the failure point, with its error variable temporarily bound. The previous binding is restored (or the name removed if fresh) on every handler exit. Other bindings from partial execution persist. Handler errors propagate outward. The core exception rules make these completions and environments explicit.
Timeout (non-retryable):
When a task handler exceeds its timeout deadline, a non-retryable timeout Failure is raised and its cooperative cancellation token is set. Cancellation-aware handlers and adapters stop and are joined; legacy synchronous handlers retain compatibility but cannot promise prompt cancellation.
Assert (pass):
\[\langle [\texttt{assert}\ e,\ c] \cdot S,\ E \rangle \;\longrightarrow\; \langle S,\ E \rangle \quad \text{when}\ \mathcal{E}\lbrack\!\lbrack e \rbrack\!\rbrack_E = \textit{true}\]
Assert (fail):
\[\langle [\texttt{assert}\ e,\ c] \cdot S,\ E \rangle \;\longrightarrow\; \textbf{AssertionError}(c) \quad \text{when}\ \mathcal{E}\lbrack\!\lbrack e \rbrack\!\rbrack_E = \textit{false}\]
Pipeline call:
\[\langle [\texttt{let}\ x = \texttt{run}\ W'\ \texttt{with}\ \{k_i{:}e_i\}] \cdot S,\ E \rangle \;\longrightarrow\; \langle S,\ E[x \mapsto \text{exec}(W',\ \{k_i \mapsto \mathcal{E}\lbrack\!\lbrack e_i \rbrack\!\rbrack_E\})] \rangle\]
The target pipeline \(W'\) is executed in a fresh scope with its parameters bound from the arguments. The result is the pipeline's return value.
Return:
\[\langle [\texttt{return}\ e] \cdot S,\ E \rangle \;\longrightarrow\; \mathcal{E}\lbrack\!\lbrack e \rbrack\!\rbrack_E\]
4. Determinism
Expression determinism (conditional). For a fixed environment and a deterministic expression evaluator, successful evaluation has at most one value. This does not assert that every expression evaluates successfully.
Pure task handlers alone do not guarantee deterministic pipeline outcomes: timeouts, shared budgets, and races can expose scheduling differences. The sequential core has a conditional determinism result when expression and call outcomes are fixed; it does not establish termination.
For an ordinary parallel join whose branches all succeed with fixed values, disjoint result bindings make the merged environment independent of completion order. This conditional property does not imply deterministic failure behavior.
The runtime additionally enforces that task handler outputs and final pipeline return values conform to their declared DSL types; malformed runtime values are execution errors even if the surrounding program parsed and type-checked successfully.
Declared tools are also validated dynamically: tool call arguments and tool results must conform to their DSL signatures.
5. AGL 0.3 nominal and sum types
AGL 0.3 extends the type grammar with nominal records, closed unions, and results:
\[\tau ::= \cdots \mid \texttt{Record}[R] \mid \texttt{Union}[U] \mid \texttt{Result}[\tau_o,\tau_e]\]
A record environment \(\mathcal{R}\) maps each record name to its field row, and a union environment \(\mathcal{U}\) maps each union name and variant to a field row.
Record construction:
\[\dfrac{\mathcal{R}(R)=\{f_i{:}\tau_i\}\quad \forall i.\ \Gamma\vdash e_i:\tau_i}{\Gamma\vdash R\{f_i:e_i\}:\texttt{Record}[R]}\]
The provided field domain must equal the declared field domain. Record[R] is assignable only to itself (including through transparent aliases); equal field structure does not make two record names interchangeable.
Union construction:
\[\dfrac{\mathcal{U}(U,V)=\{f_i{:}\tau_i\}\quad \forall i.\ \Gamma\vdash e_i:\tau_i}{\Gamma\vdash U::V\{f_i:e_i\}:\texttt{Union}[U]}\]
Result construction:
\[\dfrac{\Gamma\vdash e:\tau}{\Gamma\vdash \texttt{Ok}(e):\texttt{Result}[\tau,\alpha]}\qquad \dfrac{\Gamma\vdash e:\epsilon}{\Gamma\vdash \texttt{Err}(e):\texttt{Result}[\alpha,\epsilon]}\]
Here \(\alpha\) is a contextual inference placeholder assignable to the corresponding expected result component. It cannot be named in source or escape a checked use site.
Exhaustive match typing
For a union, the arm set must contain each declared variant exactly once, with
exactly its payload bindings. Each arm is checked with those names temporarily
bound. The same rule applies to Result::Ok { value } and
Result::Err { error }.
Restore the pattern bindings in every outgoing environment, then merge arms separately by completion kind. Only normally completing arms contribute to the post-match normal environment; returns, breaks, continues, and errors propagate independently. See the static completion analysis.
At runtime, evaluation selects the unique arm whose $type and $variant tags equal the scrutinee tags, binds its payload fields, and executes that body. Exhaustiveness guarantees that every statically valid tagged value has an arm.
Typed Err values are ordinary results. They do not take an execution-error transition and are therefore not intercepted by try/catch.
Total return paths
AGL 0.3 strengthens pipeline well-typedness by rejecting a normal fallthrough outcome. Errors need not produce a return. Every reachable return must have a compatible type, and at least one reachable return must exist. Branches and exhaustive matches merge only normal continuations; loops always include a possible zero-iteration exit. This analysis does not prove termination.
6. AGL 0.4 effect judgments
Let \(\Phi(d)\) be the declared direct effect set of task or tool \(d\). Agent execution adds model; an agent's available tools add their declared effects. Pipeline effect inference is the least fixed point of:
\[\Phi(W)=\bigcup_{r\in runs(W)}\left(\Phi(r)\cup\Phi(callee(r))\right)\]
For a pipeline with declared ceiling \(C\), well-typedness additionally requires \(\Phi(W)\subseteq C\).
Let \(safe(i)\) hold for idempotency contracts pure, idempotent, and keyed_by. A retried call with effect external_write is well typed only when \(safe(i)\) holds. A call explicitly declared non_idempotent is never retryable. When the call is agent-bound, this condition also applies to every available tool carrying external_write.
7. AGL 0.5 structured and durable transitions
For parallel map, if \(E(x_s)=[v_1\ldots v_n]\) and every mapped task produces \(u_i\), the resulting environment is \(E[x_t\mapsto[u_1\ldots u_n]]\); scheduler order does not alter index order. A race returns the first successful \(u_i\), sends cancellation to every \(j\ne i\), and joins the complete child set before continuing.
Durable execution adds history \(H\) to the configuration. Before invoking operation identity \(i\), replay checks \(H\) for task_result(i,v). If present, the transition binds \(v\) without an external call. Otherwise the runtime appends an attempt, invokes, and appends the result checkpoint. Resume is defined only when the stored program/deployment fingerprint equals the current fingerprint.
An approval without a supplied decision appends human_suspended and yields a typed suspension. Resume with a non-expired decision binds its Boolean value and appends an audited approval event.
8. AGL 0.6 module judgments
Let \(M\) map canonical module paths to interfaces and \(I\) be the directed import graph. Resolution succeeds iff \(I\) is acyclic and every qualified reference \(a::x\) names \(x\) in the public interface of alias \(a\). Private declarations remain available only while checking their owning namespace.
An interface fingerprint is computed from canonical serialized public signatures and effects. Package/API compatibility is structural over these interfaces: removal or signature/effect change is breaking; addition is compatible.
Sequential core semantics
Status: sequential control-flow model with Rust regression coverage. This formalization covers the core shared by AGL 0.2–0.6 and refines the semantics overview. Control-flow propagation and lexical restoration are implemented and tested. Expressions and calls remain abstract; this is not a complete correspondence or soundness proof for AGL.
Scope and observations
We model sequencing, task-result binding, conditionals, optional binding, loops, exceptions, assertions, and return. Programs are already parsed and names resolved. Expressions and calls are explicit parameters of the semantics. Concurrency, retry scheduling, budgets, cancellation, approval suspension, persistence, and module resolution require additional judgments and are outside this core.
The observable result is a completion together with an environment. An execution
error retains the environment at the point of failure: try is not a transaction.
External effects are not rolled back either, but their histories are not modeled
here. Err(v) is an ordinary tagged value, distinct from an execution error.
Syntax and judgments
Let names be \(x\), values be \(v\), execution errors be \(\epsilon\), and finite environments be \(\rho : \mathrm{Name} \rightharpoonup \mathrm{Value}\). Values include Booleans, numbers, strings, null, lists, and objects (including tagged records, unions, and results). A block \(B\) is a finite statement sequence:
\[ \begin{aligned} s ::= {}& \operatorname{bind}(x,r) \mid \operatorname{if}(e,B_t,B_f) \mid \operatorname{iflet}(x,e,B_t,B_f) \\ &\mid \operatorname{while}(e,B) \mid \operatorname{break} \mid \operatorname{continue} \\ &\mid \operatorname{try}(B_t,x,h,B_c) \mid \operatorname{assert}(e,m) \mid \operatorname{return}(e). \end{aligned} \]
Here \(r\) is a complete run statement without its target binding; \(h\) selects
string or structured catch payloads. Missing else means the empty block.
Completions are disjoint constructors:
\[ q ::= N \mid R(v) \mid Bk \mid Ct \mid X(\epsilon). \]
They mean normal completion, return, break, continue, and execution error. We use the terminating big-step judgments
\[ \langle s,\rho\rangle\Downarrow(q,\rho') \qquad \langle B,\rho\rangle\Downarrow(q,\rho'). \]
The expression interface is \(\operatorname{eval}(e,\rho)\in \operatorname{Val}(v)+\operatorname{Error}(\epsilon)\), a deterministic partial function. Undefined evaluation denotes divergence, not an arbitrary value. The abstract call relation is \(\langle r,\rho\rangle\Downarrow_{call}o\), where \(o\) is a value or execution error. Calls cannot mutate the caller's environment. They may have external effects and need not be deterministic. Nested pipelines use fresh parameter environments and convert \(R(v)\) to a call value; escaping \(N\), \(Bk\), or \(Ct\) is a call error. Return values must pass the declared runtime type check before crossing the call boundary.
Sequencing and primitive statements
\[ \frac{}{\langle [],\rho\rangle\Downarrow(N,\rho)} \qquad \frac{\langle s,\rho\rangle\Downarrow(N,\rho_1)\quad \langle B,\rho_1\rangle\Downarrow(q,\rho_2)} {\langle s::B,\rho\rangle\Downarrow(q,\rho_2)} \]
\[ \frac{\langle s,\rho\rangle\Downarrow(q,\rho')\quad q\ne N} {\langle s::B,\rho\rangle\Downarrow(q,\rho')} \]
For a successful call, binding replaces an existing name if present:
\[ \frac{\langle r,\rho\rangle\Downarrow_{call}\operatorname{Val}(v)} {\langle\operatorname{bind}(x,r),\rho\rangle\Downarrow(N,\rho[x\mapsto v])} \qquad \frac{\langle r,\rho\rangle\Downarrow_{call}\operatorname{Error}(\epsilon)} {\langle\operatorname{bind}(x,r),\rho\rangle\Downarrow(X(\epsilon),\rho)} \]
return e completes with \(R(v)\) when evaluation gives \(\operatorname{Val}(v)\).
break and continue complete with \(Bk\) and \(Ct\). All three leave \(\rho\)
unchanged. assert true, m completes with \(N\); assert false, m completes
with \(X(\operatorname{assertion}(m))\).
For every statement that first evaluates an expression, an evaluation error immediately gives \((X(\epsilon),\rho)\) without entering a branch or body. Conditions are required to evaluate to Booleans; another value produces a condition-type error in this proposed contract. This deliberately does not adopt the Rust runtime's truthiness coercion for unchecked inputs.
Conditionals and temporary bindings
An if evaluates its condition once, then has exactly the completion and
environment of the selected block (\(B_t\) for true, \(B_f\) for false).
Define restoration of one name, leaving all other final bindings unchanged:
\[ \operatorname{restore}_x(\rho_0,\rho_1)= \begin{cases} \rho_1[x\mapsto\rho_0(x)] & x\in\operatorname{dom}(\rho_0),\\ \rho_1\setminus\{x\} & \text{otherwise}. \end{cases} \]
An if let whose expression is null executes \(B_f\) in \(\rho\).
For a non-null value:
\[ \frac{\operatorname{eval}(e,\rho)=\operatorname{Val}(v)\quad v\ne\mathrm{null} \quad\langle B_t,\rho[x\mapsto v]\rangle\Downarrow(q,\rho')} {\langle\operatorname{iflet}(x,e,B_t,B_f),\rho\rangle \Downarrow(q,\operatorname{restore}_x(\rho,\rho'))} \]
Restoration applies to every completion, including errors. Returning a value does not undo that value; restoration changes the environment only.
Loops
Write \(W=\operatorname{while}(e,B)\). A false condition gives \((N,\rho)\). For a true condition, the complete body case analysis is:
| Body completion from \(\rho\) | Loop behavior |
|---|---|
| \((N,\rho_1)\) or \((Ct,\rho_1)\) | Evaluate \(W\) again in \(\rho_1\); use its result |
| \((Bk,\rho_1)\) | Complete with \((N,\rho_1)\) |
| \((R(v),\rho_1)\) | Complete with \((R(v),\rho_1)\) |
| \((X(\epsilon),\rho_1)\) | Complete with \((X(\epsilon),\rho_1)\) |
For example, the iteration rule is:
\[ \frac{\operatorname{eval}(e,\rho)=\operatorname{Val}(\mathrm{true})\quad \langle B,\rho\rangle\Downarrow(q_1,\rho_1)\quad q_1\in\{N,Ct\}\quad \langle W,\rho_1\rangle\Downarrow(q,\rho_2)} {\langle W,\rho\rangle\Downarrow(q,\rho_2)}. \]
Each loop consumes only its own body's break/continue. An inner loop consumes those signals before its containing block sees them. An infinite loop has no finite derivation; lack of a derivation alone does not prove divergence because the call relation is abstract.
Exceptions
Let \(\operatorname{payload}_h(\epsilon)\) encode an execution error as a string
or a Failure value. A try block's non-error completion passes through:
\[ \frac{\langle B_t,\rho\rangle\Downarrow(q,\rho')\quad q\in\{N,R(v),Bk,Ct\}} {\langle\operatorname{try}(B_t,x,h,B_c),\rho\rangle\Downarrow(q,\rho')}. \]
On error, the catch starts from the failure environment, not the entry environment. Only the catch variable is temporary:
\[ \frac{\langle B_t,\rho\rangle\Downarrow(X(\epsilon),\rho_1)\quad \langle B_c,\rho_1[x\mapsto\operatorname{payload}_h(\epsilon)]\rangle \Downarrow(q,\rho_2)} {\langle\operatorname{try}(B_t,x,h,B_c),\rho\rangle \Downarrow(q,\operatorname{restore}_x(\rho_1,\rho_2))}. \]
A catch error propagates outward; the same catch does not handle it again. The runtime performs this lexical cleanup for catch bindings, including when the handler returns, breaks, continues, or raises another execution error.
Implementation and regression cases
block and with_bindings in the runtime implement completion propagation and
lexical restoration. The checker uses an internal BlockFlow summary, with
separate normal, return, break, continue, and failure outcomes. None denotes
an unreachable outcome; an empty environment denotes a reachable outcome with
no definitely available names.
The executable regressions in tests/semantics.rs and tests/conformance/ cover:
| Obligation | Corrected behavior |
|---|---|
| Try passes through non-error completions | Return, break, and continue reach their enclosing boundary |
| Temporary bindings restore on every exit | Optional, match, and catch names restore or disappear before propagation |
| Returns are checked independently | Every reachable return has a compatible type and its own source diagnostic |
| Parallel branches share only the entry scope | A sibling's result cannot be referenced before the join |
| Loops preserve back-edge types | Normal/continue paths preserve entry types; zero iterations and breaks determine the exit scope |
| Catch models partial execution | Its input contains only bindings compatible across every failure prefix |
For example, this now returns 1, rather than falling through to 3:
language "0.2";
pipeline probe() -> Number {
try { return 1; } catch err { return 2; }
return 3;
}
This returns "original" when supplied err = "original", rather than leaking
the assertion message through the shadowed name:
language "0.2";
pipeline probe(err: String) -> String {
try { assert false, "failure"; } catch err {}
return err;
}
Static completion analysis
Expression inference and structural assignability are unchanged. Each statement produces a completion summary. Environments from different paths are joined only within the same completion kind, retaining names present with mutually assignable types on both paths. An unreachable path contributes nothing. Return outcomes retain their expression type and source span rather than an outgoing environment, because the returned value has already been evaluated.
- Sequence: check the next statement only from the normal outcome. Accumulate returns, breaks, continues, and failures separately. Unreachable tails have no typing contribution; the separate analyzer still reports warnings.
- Primitive statements: successful calls and approvals extend the normal environment; assertions preserve it. Return has only a return outcome; break and continue have their respective loop-control outcomes. The enclosing block conservatively includes a possible failure before each statement, matching the runtime's time-budget check and expression/call failures.
- Branches: check both alternatives from the same entry environment and merge each completion kind separately. Missing else is an empty normal block.
- Temporary scopes: restore the entry type or remove the name in every outgoing environment before merging. Returned types remain unchanged.
- Loops: check the body from the entry environment. Each normal/continue back-edge must retain every entry binding at an assignable type. Merge the entry environment (zero iterations) with break exits for the normal output. Consume break/continue and propagate returns/failures. No constant-condition reasoning or termination inference is performed.
- Catch: merge the try block's failure environments, add the error binding, and check the handler from that environment. Restore the error name afterward. Successful try completions pass through; handler errors propagate outward. The time-budget check before the try statement itself belongs to the enclosing block and is not handled by that try's catch.
- Parallel: infer each run from the same snapshot, then publish fresh, distinct target types together. Partial results on a failure do not establish any additional definitely available names. Ordered map and race retain their existing result-type checks and contribute normal or failure outcomes.
- Pipeline boundary: require at least one reachable return and check each against the declared type. AGL 0.2 permits a normal fallthrough outcome; 0.3–0.6 reject it. Test blocks have no declared return-type requirement.
This is a conservative implementation contract, not a mechanized typing proof.
Properties and remaining proof obligations
Conditional determinism (this core only). Fix a single-valued expression evaluator and a single-valued call relation. For every \(B,\rho,q_1,\rho_1,q_2,\rho_2\), if both \(\langle B,\rho\rangle\Downarrow(q_1,\rho_1)\) and \(\langle B,\rho\rangle\Downarrow(q_2,\rho_2)\), then \(q_1=q_2\) and \(\rho_1=\rho_2\).
Proof by mutual induction on the first statement/block derivation, universally quantifying the competing derivation. Empty blocks and primitives are unique by definition and the evaluator/call assumptions. For sequence, the statement induction hypothesis fixes whether the tail runs and its initial environment; the block hypothesis fixes the tail result. For conditionals and optional binding, evaluation fixes the branch, its hypothesis fixes the result, and restoration is a function. For loops, evaluation fixes exit versus iteration; the body hypothesis fixes the completion, and the recursive loop hypothesis applies only in the normal/continue cases, to a strictly smaller derivation. For try, the body hypothesis fixes whether catch runs; the catch hypothesis and functional payload/restoration fix its result. These exhaust the syntax. This is a paper proof for the parameterized rules, not a mechanized proof or a proof about the implementation.
Noninterference of skipped tails. By inversion of sequencing, a statement with completion other than \(N\) prevents evaluation of its remaining block.
No termination or full type-soundness claim. Pure handlers alone do not imply
determinism for the full language: deadlines, shared budgets, and races can
make outcomes depend on scheduling. A while true { continue; } also refutes
unconditional termination even without calls.
The checker now tracks completions separately as described above. Remaining proof work includes exhaustive expression rules, preservation of the inferred loop invariants, and correspondence between the checker, this model, and the runtime. Tagged match cleanup has regression coverage but still needs a formal rule in this core syntax.
Retry/fallback and resource transitions, structured-concurrency traces, and durable replay with crash points remain outside this model. In particular, parallel failure cleanup, replay identity/final-result selection, and approval policy enforcement remain separate runtime reliability issues. Full-language progress and preservation remain open.
Real Tool Calling Plan
This plan upgrades AgentLang from a workflow DSL with tool metadata into a runtime that can execute real tool-calling agent tasks while preserving the language's typed orchestration model.
Goals
- Make tools first-class DSL/runtime objects.
- Preserve explicit pipeline orchestration in the DSL.
- Add an execution path where agent tasks can call tools and must still satisfy declared task types.
- Keep deterministic task handlers and agent-driven task handlers as separate execution modes.
Implementation Order
1. First-class tool declarations
-
Add
tooldeclarations to the AST and parser. -
A tool declaration will look like:
tool web_search(query: String) -> List[Obj{title: String, url: String, snippet: String}] {} -
Tools are declarations only; execution remains in the runtime registry.
-
Checker responsibilities:
- enforce unique tool names
- allow agents to reference only declared tools
2. Runtime tool registry
- Add a runtime tool registry parallel to the existing task registry.
- Each tool runtime entry must include:
- typed argument contract from the DSL declaration
- typed return contract from the DSL declaration
- Rust handler implementation
- Runtime must validate tool input values before calling handlers and validate tool outputs after handler execution.
3. OpenAI tool-calling loop
- Extend the OpenAI adapter from plain text completion to a loop that supports:
- tool definitions in the request
- model-emitted tool calls
- dispatching tool calls into the runtime tool registry
- feeding tool outputs back into the model until final output is produced
- Add execution limits:
- max tool calls per task
- clear error on unknown tool name or invalid tool args/output
4. Agent-task execution mode
-
Add an explicit agent-task syntax:
task investigate(topic: String) -> Obj{summary: String, sources: List[String]} by agent {} -
Semantics:
- deterministic tasks continue to use Rust task handlers
by agenttasks are solved by the model bound in the run statement's agent- the runtime exposes only the tools allowed by that agent declaration
5. Runtime contract enforcement
- For agent tasks and tools, validate:
- task args
- tool args
- tool outputs
- final agent task output
- pipeline return values
- Structured outputs must conform to the declared DSL return type.
6. Examples after each slice
- After tool declarations: add a syntax-only example and run parse/type-check validation.
- After the tool registry: add a deterministic tool-backed example and run it in mock mode.
- After the OpenAI tool loop and agent tasks: add a real live-capable example and run it in mock mode and live mode.
7. Tests
- Add tests for:
- parsing
tool - agent references to undeclared tools
- invalid tool args/output
- unknown tool call requested by the model
- max-tool-call budget
- agent task output validation
- end-to-end tool-calling happy path
- parsing
Non-Goals For This Pass
- Durable execution
- Arbitrary user-defined prompt blocks in the DSL
- General loops or recursive task execution
- Provider-specific syntax in the language
Design Default
AgentLang remains an orchestration DSL first. Tool calling is a runtime capability attached to agent tasks, not a replacement for explicit pipelines.
AGL behavioral contracts and enforcing runtime
Status: proposal, not an implemented language version
Baseline: AGL 0.6, repository revision 78dd552
1. Purpose and decision
Extend AGL's existing data types, effects, idempotency contracts, and durable execution with resource-scoped authority and checked action protocols. Expose the same contracts through a Rust framework API so applications can construct checked workflows without writing AGL source.
The proposed guarantee is:
Every dispatched action has validated arguments, sufficient current authority, permitted effects, and a valid protocol transition. An approval-protected action is bound to an authentic approval of that exact action. These properties hold for developer-authored workflows and model-proposed actions at the mediated execution boundary.
This guarantee assumes correctly implemented trusted adapters and an enforcing host. It does not prove that model output is true, that tools meet their business specifications, that external systems provide exactly-once execution, or that a workflow eventually completes.
The review decision is whether to adopt the contract model and staged delivery below. This document does not reserve a new language version, change AGL 0.6, or declare any proposed syntax supported. All new syntax and API sketches are illustrative; a versioned normative specification must accompany implementation.
2. Existing behavior and the gap
AGL already implements a substantial part of this design. The proposal builds on these mechanisms rather than replacing them.
| Existing mechanism | Implementation evidence | Proposed extension |
|---|---|---|
| Typed tasks, tools, records, unions, and results | src/ast.rs, src/checker.rs | Opaque protocol handles and authority-bearing values |
| Transitive effect inference and declared ceilings | infer_program_effects in src/checker.rs; AGL 0.4 | Separate authority requirements from effects; retain conservative inference |
| Retry checks for external writes and non-idempotent tasks | check_run, safe_to_retry in src/checker.rs | Per-tool logical identities, uncertain outcomes, and reconciliation |
| Provider capability requirements | src/deployment.rs | A distinct vocabulary for authority; preserve provider requires |
| Tool declarations, schema validation, and policy hooks | ToolRegistry::execute_contextual in src/adapters/tools.rs | One dispatcher carrying actor, authority, protocol, and approval context |
Human suspension returning Bool | Stmt::Approve, ExecutionContext::with_approvals, src/runtime.rs; AGL 0.5 | Authenticated decisions producing permits for exact proposals |
| Deployment effect/tool restrictions | src/policy.rs; AGL 0.6 | Per-action authorization and checked protocol preconditions |
| Stable task invocation IDs and result replay | src/runtime.rs, src/context.rs, src/event_store.rs | Durable child tool operations and atomic permit/resource transitions |
| Shared pipeline budgets and cooperative cancellation | src/runtime.rs | Authorization and resource reservation integrated with scheduling |
| Native Rust embedding and extension API version 1 | src/lib.rs, src/extension.rs | A checked framework entry point with versioned contracts |
In particular, DeploymentPolicy::validate_program currently uses
has_approval to detect whether an external-write pipeline contains an approval
statement somewhere in its control flow. That structural summary does not
establish that a successful approval precedes each write, that rejection prevents
the write, or that the decision covers its arguments. Existing approval values
are ordinary booleans, not evidence of authorization.
The current tool registry validates declared arguments and results. Its optional
Invocation carries task identity and cancellation, but not a resource-specific
grant or an approval permit. The registered tool handler receives JSON arguments.
The new dispatcher must close this context gap for protected operations.
Native Rust remains trusted in-process. URL/path argument checks are policy helpers, not confinement of arbitrary Rust code or all network/file accesses.
3. Scope
The first release covers:
- Typed, immutable action proposals and exact-action approvals.
- Authority grants scoped to principal, operation, and resource.
- Finite resource protocols with opaque handles and affine consumption.
- A shared contract representation and dispatcher for AGL and Rust embedding.
- Durable operation identity, atomic claims, and explicit uncertain outcomes.
- Static diagnostics and runtime denials with an auditable reason.
Full session types, general dependent types, arbitrary refinement proofs, automatic inference of native code effects, and model-internal reasoning verification are outside this release. Evidence protocols are a follow-on use case of the same primitive, not an initial claim of semantic truth verification.
4. Contract model
4.1 Distinct concepts
| Concept | Question answered | Example |
|---|---|---|
| Data type | What values may flow? | Email, Receipt |
| Authority | Who may exercise which operation on which resource? | Send from a particular mailbox |
| Effect | What may execution do? | network, external_write |
| Protocol | When is the operation legal for this resource? | Draft must be approved before dispatch |
| Idempotency | When may the same logical operation be attempted again? | Receiver deduplicates a stable key |
| Budget | What resources may execution reserve or consume? | At most ten tool calls |
Existing agent requires continues to express model/provider features such as
reasoning and tool_calling. A new authority requirement must not overload this
field. Likewise, application-defined effect names do not themselves grant access.
4.2 Tool and task contracts
The shared intermediate representation contains:
OperationContract {
qualified_name, contract_version, implementation_fingerprint,
input_type, output_type,
authority_requirements,
possible_effects,
protocol_preconditions,
protocol_outcomes,
approval_requirement,
idempotency_contract,
scheduling_contract
}
Authority requirements select resources from validated arguments or opaque
handles. Protocol outcomes describe success, definite failure, and uncertain
completion separately. Effect sets describe possible execution, including error
paths and compensation. Financial operations may add an application effect such
as financial_write, but must also retain external_write.
Contracts are trusted declarations about host implementations. Registration must match source/module contract fingerprints before execution. A handler with a different contract cannot silently replace a checked implementation.
4.3 Authority grants
A grant contains a trusted issuer, subject principal, allowed operations,
resource scope, validity interval, and revocation identity. Possessing a type
named EmailSend is insufficient to send from every mailbox.
Only an authenticated host authorization service may issue grants. The model may request authority but cannot create it. Delegation may narrow scope and expiry; it may never increase either. Static checking verifies required grant kinds and scope relationships where known. Dispatch checks concrete arguments, identity, expiry, revocation, and deployment restrictions.
Effective authority is the intersection of source requirements, granted scope, and deployment policy. A tool omitted from an agent's tool set remains unavailable even when the host has registered it. A model naming another declared tool must receive a denial at dispatch, not just an omission from its advertised schema.
Read grants may be shared when their contracts permit it. One-shot approvals and exclusive state transitions are affine: they may be consumed at most once.
5. Resources and protocol typing
5.1 State belongs to a resource
Represent protocol state as Resource[R, S], where R names a resource kind and
S a declared finite state. This is proposed notation, not current TypeExpr
syntax. An email draft and a report may have independent states within the same
workflow; one global Agent<State> would conflate those lifecycles.
A handle identifies a resource ID, immutable revision, payload digest, protocol definition, and state. Constructors are inaccessible to ordinary AGL expressions, model output, and unprivileged host clients. Nominal typing alone does not make a value unforgeable; runtime handle lookup and protected issuance are required.
The initial email protocol is:
Draft --propose--> Proposed --human approval--> Authorized
| |
+--human denial--> Denied +--dispatch claim--> Dispatching
|
+-------------------------+------------------+
| | |
Sent FailedNoEffect Unknown
Unknown means the dispatcher cannot establish whether the external action took
effect. Reconciliation may establish Sent or FailedNoEffect. It must not
silently return the resource to a reusable authorized state.
A proposal snapshots all effect-relevant arguments. Editing a draft creates a new revision and requires a new proposal and approval. Editing cannot mutate an already authorized snapshot in place.
5.2 Static judgment
Use an explicit output environment to capture consumption and state changes:
Γ ; Δ ; C ⊢ e : τ ! ε ▷ Δ'
Γcontains ordinary reusable values.Δcontains owned resource handles and affine permits.Ccontains available authority requirements/grants.τis the result type andεthe upper bound on possible effects.Δ'contains resources available after evaluation.
For a declared operation the checker requires compatible inputs, sufficient
authority kinds, permitted effects, and matching input states. Consumed bindings
leave Δ; returned handles enter Δ' according to the selected outcome.
At a conditional or match join, a resource is available only when every
reachable predecessor preserves the same ownership and compatible state.
Differing states require an explicit tagged outcome and exhaustive match; an
ordinary boolean cannot narrow a resource to Authorized.
A loop must preserve its resource invariant at the back edge. A permit consumed by the body cannot reappear on a later iteration. The first implementation may reject loops carrying changing protocol states rather than infer complex invariants.
Entering try/catch cannot resurrect consumed handles. Dispatch failures expose
typed outcomes that say whether the action was definitely unperformed or may
have occurred. A fallback value cannot stand in for a permit or proof handle.
5.3 Dynamic choices
Static inference includes every tool an agent may select, as AGL 0.4 already does. Giving a researcher a write tool therefore adds its effects even if the prompt says not to use it. The effect ceiling must be satisfied without predicting which tool the model chooses.
The runtime can reduce the advertised tool set according to the current resource states, but advertising is a usability feature. The dispatcher independently validates every proposal against current state and authority. Invalid proposals produce structured feedback; they do not execute.
6. Exact-action approval
6.1 Proposed interface
The following is a semantic sketch, not accepted AGL syntax:
proposal = propose send_email(email_revision)
decision = request_approval(proposal, role = operator, expires = 3600)
match decision {
Granted { permit } => dispatch(proposal, permit)
Denied { reason } => return Rejected(reason)
}
propose is deterministic and performs no external write. request_approval
contributes human; dispatch contributes the tool's effects. Only the trusted
approval resolver may produce Granted. A model returning JSON with that tag
does not mint a permit.
A permit authorizes one logical operation. It binds:
- Authenticated approver, requesting principal, execution ID, and grant scope.
- Qualified operation and contract/implementation fingerprints.
- Canonical validated arguments, resource IDs and revisions, and their digest.
- Destination, account/tenant, and other effect-relevant deployment configuration.
- Policy fingerprint, issuance time, expiry, and logical operation ID.
The approval UI must present the same snapshot that dispatch will execute. Defaults must be expanded before approval; the dispatcher must not silently add or change recipients, amounts, destinations, or other effect-relevant arguments.
6.2 Canonicalization and authenticity
Canonicalization is versioned: recursively sorted object keys, preserved array order, explicit type tags, finite numeric values with one defined encoding, and materialized defaults. The MVP may restrict proposed payloads to supported schema types until this encoding has conformance fixtures. Hash the encoded snapshot with a standard cryptographic digest; a hash identifies content, not its approver.
The local implementation uses opaque IDs resolved through the trusted store. Externally submitted decisions must be authenticated and checked against a pending server-issued request and approver role. Raw client booleans, a model's message history, or user-supplied JSON cannot be accepted as authentic permits. Portable signed permits are deferred; signing-key management is not required for the local MVP.
6.3 Consumption and rejection
Permit claim, resource revision/state check, and transition to Dispatching must
be one atomic durable transaction. A second claim must fail, including claims
from a second process. Copying a serialized permit ID does not copy authority.
Rejection, expiry, revocation, or fingerprint mismatch prevents a new dispatch. An already dispatched action cannot be undone by revocation. Recovering its outcome remains legal; a further external attempt requires current authorization under the retry contract. The MVP stops and requests renewed approval when a permit expires or authority is revoked before such an attempt.
Legacy approve ... remains a Bool under its existing language versions. It
does not become a permit by implicit conversion.
7. Enforcing dispatcher
All protected task calls and tool calls, including nested calls inside model adapters and native contextual handlers, must pass through one dispatcher.
AGL source / Rust workflow builder
|
checked contract IR
|
model action proposal
|
enforcing dispatcher
|
trusted handler
Before starting a new logical operation, the dispatcher must:
- Resolve its qualified contract and verify registration fingerprints.
- Validate arguments and resolve opaque handles through trusted lookup.
- Check the agent tool set, concrete authority scope, and deployment restrictions.
- Check effect ceilings and protocol preconditions.
- Validate any approval against the exact canonical proposal.
- Reserve scheduling/budget capacity and recheck authorization after waiting.
- Atomically claim permits/resource revisions and persist an operation intent.
- Invoke the handler with stable operation identity and cancellation context.
- Validate and persist the outcome and resulting protocol state before releasing reusable handles to the caller.
Scheduling capacity reserved before a failed claim must be released; attempted work already charged under budget semantics is not refunded merely because a result is unsuccessful. Failure to persist an intent prevents dispatch. Failure to persist a result after an external effect produces an uncertain outcome.
The handler receives the authorized snapshot and contextual execution services. Protected handlers must not be callable through the legacy unrestricted registry entry point. Native hosts that deliberately bypass this boundary fall outside the guarantee. Untrusted extension confinement requires a separate sandbox design.
The initial condition on runtime traces is a safety property: every dispatch has a preceding valid claim, and every committed state transition follows its contract. Neither property establishes liveness or task utility.
8. Durability, retries, and uncertain outcomes
Each logical tool operation has its own persisted ID beneath its parent task invocation. Retries reuse that ID, proposal snapshot, and destination idempotency key. A parent task ID alone is insufficient when a model invokes a tool several times or changes its decisions after restart.
Persist each tool intent and outcome before allowing the adapter to continue. Persist the adapter continuation/tool-call transcript needed to match pending operations on resume. Recovery must not ask the model to regenerate an unfinished action and assume it will produce the same arguments. A changed action requires a new proposal; a new write requires its own authorization.
The operation record has the following lifecycle:
Pending -> Claimed -> Completed
-> FailedNoEffect
-> Unknown -> ReconciledCompleted | ReconciledNoEffect
Claimed is persisted before I/O. A crash while claimed is treated as Unknown
until receiver lookup, authoritative handler evidence, or another declared
reconciliation mechanism establishes an outcome. Even invalid response JSON can
follow a successful external write and must not be classified as no effect.
Automatic retry is permitted only when:
- The failure selector permits it and budgets/deadlines allow it.
- The existing idempotency contract allows the same logical operation to repeat.
- Required authority and approval remain valid for the next attempt.
- An uncertain write is safely deduplicated by the destination or first reconciled.
Payment/email APIs are not assumed to support idempotency. Without a receiver contract or authoritative reconciliation, an uncertain non-idempotent operation requires human resolution and receives no blind retry.
The dispatcher guarantees at most one initial claim per permit. It cannot
guarantee exactly-once external effects across an I/O/commit boundary without
cooperation from the destination. An idempotency key is a host/receiver promise,
not a proof established by its String type.
The existing append/load EventStore API cannot by itself express an atomic
cross-process claim. Introduce a separate transactional OperationStore contract
for proposal snapshots, decisions, permit claims, resource revisions, and tool
outcomes. SQLite is the first implementation. Claim and intent must commit
together; audit events are committed in the same transaction or an atomic outbox.
Trace appends alone must not be used as a mutual-exclusion primitive.
Replay substitutes completed operations without contacting providers or tools. Recovery checks source, deployment, policy, protocol, canonicalization, and contract fingerprints. Revocation prevents new attempts, not reading a historical receipt. Retention must preserve pending intents and consumed-permit tombstones for the lifetime of any credential/reference that could otherwise be replayed.
9. Parallel execution and resources
Existing effect unions remain conservative. Disjoint effect names do not prove that operations are independent, and shared effect names do not prohibit parallel reads.
- Parallel branches receive disjoint owned handles/one-shot permits. Shared read access requires an explicit shareable grant and resource contract.
- Runtime compare-and-swap on resource revision prevents concurrent transitions even when distinct permits accidentally name the same resource revision.
- A permit cannot be captured by both branches or duplicated by
parallel map. - The MVP forbids protected external writes inside
race: cancelling a losing branch cannot retract an effect it already performed. Read/model races retain their existing cancellation semantics. - Admission reserves tool calls, retries, and concurrency before work starts. Nested scopes must also respect enclosing ceilings; a child cannot evade its parent's budget by creating another pipeline scope.
- Token/cost usage reported after execution remains accounting rather than a strict predictive bound. Hard limits require declared upper-bound reservation and a provider interface capable of honoring the corresponding limit.
Cancellation before claim prevents dispatch. Cancellation after an effect starts
may produce Unknown; it does not release the consumed permit for another action.
10. Framework API and implementation boundaries
The production implementation remains Rust. Reuse AGL's existing parser, checker, runtime, adapters, and embedding API; do not create a second execution engine.
The proposed public API has these responsibilities:
ContractRegistry: register versioned trusted operation contracts
WorkflowBuilder: construct the same IR as the AGL frontend
check_workflow: Workflow -> CheckedWorkflow | Diagnostics
Dispatcher: execute only checked operations or validated model proposals
ApprovalResolver: authenticate decisions for persisted pending proposals
OperationStore: atomically claim and advance protected operations/resources
CheckedWorkflow, permits, and state handles have private constructors. Rust
typestate wrappers can improve developer ergonomics, but runtime checks remain
authoritative for JSON boundaries, revocation, persistent state, and model choices.
A compiler certificate is not authorization to execute with arbitrary deployment
bindings.
Module interfaces include authority requirements, protocols, affine ownership, effects, approval requirements, and outcome/idempotency contracts in their fingerprints. Adding authority/effects, strengthening preconditions, or changing states/approval bindings is an exported compatibility change. Resume compatibility is stricter than source API compatibility because old approvals must bind the same executable contract.
Version the changed extension contracts; do not add mandatory behavior to API version 1 while claiming unchanged compatibility. Legacy handlers remain usable for legacy workflows. They cannot register as protected operations until they provide the new context and contracts. No new dependency is approved by this document; any implementation requiring one follows repository approval rules.
11. Observable evidence obligations
After the email MVP, express evidence gathering using the same resource model:
Question<Unresearched>
-> Evidence<Collected>
-> Evidence<CheckedAgainstPolicy>
-> Report<Reviewed>
-> Publication
Evidence receipts record source identity, retrieved content digest, retrieval
time, and the question/revision they concern. A trusted validator issues an
attestation identifying its policy/version, inspected evidence, report digest,
and result. Merely running search does not establish that sources support a
particular answer. Publication must bind the attestation to the exact report.
The checker proves required receipts/attestations are present and correctly bound.
The assurance about relevance, corroboration, or truth is only as strong as the
validator. An LLM-based validator yields a recorded judgment, not a mathematical
proof. Do not expose a misleading universal Verified state.
12. Diagnostics and audit
Allocate stable diagnostic codes during implementation for missing authority, invalid protocol state, consumed permit use, incompatible branch ownership, unbound approval, unsafe retry, and unsupported protected race. Static diagnostics include source spans, required/actual state, and a concrete remediation.
Runtime denials distinguish schema errors, authorization denial, expired/revoked approval, proposal mismatch, stale revision, claimed permit, persistence failure, and uncertain completion. Policy denial must not be reported as an ordinary network error or treated as retryable.
Audit records contain execution/task/tool operation IDs, principal, contract and policy fingerprints, resource revision, proposal digest, decision actor/time, claim, attempt, outcome, and reconciliation. Redact secrets before persistence. Store payload snapshots through protected storage references; audit traces must not contain bearer grants or permit secrets. Digests of low-entropy sensitive fields are not automatically anonymous.
13. Delivery and acceptance
Stage 1: contracts and exact-action email approval
Implement shared contracts, checked dispatch, opaque proposals/permits, a trusted approval resolver, and the transactional local operation store. Demonstrate with deterministic Rust handlers and a fake email destination; live email is unnecessary.
Acceptance scenarios:
| Scenario | Required result |
|---|---|
Research workflow includes send_email | Effect ceiling rejects the workflow |
| Declared tool is absent from this agent's tool set | Runtime denies a forged model call |
| Send without a permit | Static rejection for declared code; runtime denial for model proposals |
| Approval appears after a write or on an unrelated branch | Does not authorize the write |
| Human rejects, expires, or lacks the required role | No destination call |
| Recipient/body/account changes after approval | New proposal/approval required |
Model or external client fabricates a Granted value | No permit is issued |
| Two threads/processes claim one permit | One claim commits; at most one initial dispatch |
| Two permits target one exclusive revision | At most one state transition claims that revision |
| Crash after intent, before/after destination success | Resume reconciles or remains Unknown; no blind resend |
| Receiver supports deduplication | Retries use the same operation ID/key and create one logical send |
| Receiver lacks deduplication and outcome is uncertain | Suspend for resolution |
| Store fails before claim commit | No destination call |
| Result validation/persistence fails after destination success | Outcome remains uncertain; permit stays consumed |
| Policy or contract fingerprint changes | Resume/new dispatch fails closed with an explanation |
Tests must include real SQLite concurrency/atomicity checks and fault injection at the claim, external-call, and completion boundaries. Counting mock calls only within one process is insufficient evidence for cross-process claims.
Stage 2: AGL protocol and ownership checking
Choose the surface grammar, add a versioned normative specification, and implement AST/parser/formatter/checker/runtime support together. Add module-interface compatibility rules, source-span diagnostics, migration guidance, and positive and negative conformance fixtures for branching, loops, catch/fallback, parallel capture, stale handles, and forged host/model values.
Stage 3: evidence and bounded delegation
Add evidence attestations and attenuated delegation using the same dispatcher and opaque resource primitives. Measure usability and rejected valid workflows before adding general session types, refinements, or symbolic resource arithmetic.
For every stage, retain existing versioned conformance/regression behavior, run the repository checks, document guarantee assumptions, and exercise mock CLI and embedding paths without credentials. No stage is complete on parser support alone.
14. Alternatives and open decisions
| Approach | Strength | Cost / when to choose it |
|---|---|---|
| Runtime policy middleware only | Adopts easily around existing dynamic agents | No static protocol/ownership guarantee; appropriate for gradual integration |
| Rust typestate library only | Strong host-language ownership with little new grammar | JSON/model/persistence boundaries still need enforcement; appropriate for a Rust-only SDK |
| AGL contracts plus shared runtime (proposed) | One model for source, framework, modules, and dynamic dispatch | Requires new IR, ownership checking, and transactional recovery |
| Verify complete generated plans upfront | Can analyze whole action sequences before I/O | Less flexible when later choices depend on observations; appropriate for bounded planning |
Resolve before implementation:
- Select protocol/authority syntax without overloading provider
requires. - Specify canonical payload encoding and choose the digest implementation.
- Define authenticated approver integration and the local operator identity model.
- Decide the new extension API version and protected-operation migration path.
- Specify reconciliation contracts for the first real destination after the mock MVP. Its idempotency guarantees determine achievable retry behavior.
The initial recommendation is a finite protocol checker plus exact-action permits and runtime enforcement. Keep the calculus small until the email and evidence examples establish where richer typing is necessary.
15. Related work
These references establish overlapping mechanisms, not equivalence to this spec or an independent assessment of implementation quality.
- Effect AI toolkits: typed tool contracts and required handler services; useful framework composition ideas.
- Pydantic AI deferred tools: explicit approval/deferred execution and discussion of client authorization boundaries.
- LangGraph interrupts: persisted human interaction and replay/idempotency responsibilities.
- Guardians: structured-plan verification using taint analysis, security automata, and SMT conditions.
- AgentSpec: a runtime policy DSL with triggers, predicates, and enforcement actions.
- Agent-C: temporal action constraints enforced with SMT checking and constrained generation.
- Language-Based Agent Control: typed agent-generated programs with capability, provenance, and information-flow policies.
- Temporal activity semantics: destination idempotency and the distinction between durable completion records and repeated external execution.
AGL language roadmap
This document is the source of truth for evolving AGL after the Rust port. The goal is not to turn AGL into a general-purpose programming language. AGL should become the smallest language that makes agentic applications reliable, portable, auditable, and resumable.
The roadmap is complete only when every acceptance criterion below is implemented, documented, covered by conformance tests, and represented in the formal semantics where applicable.
Design principles
- Reliability over convenience. Make failure, cancellation, budgets, and side effects explicit.
- Provider independence. Programs express required capabilities; deployment selects providers and models.
- Static guarantees where useful. Reject unsafe retries, invalid data flow, non-exhaustive matches, and undeclared capabilities before execution.
- Deterministic orchestration. Nondeterministic model and tool results are recorded as events around a deterministic workflow core.
- Structured execution. Child work has an owner, deadline, cancellation path, and durable identity.
- Inspectability. Source, lowered form, diagnostics, traces, and replay remain understandable without framework internals.
- A small language. Prefer orthogonal primitives and library features over overlapping syntax.
- Compatibility by evidence. Every language version has executable conformance fixtures and explicit migration notes.
Completion rules
An item is not complete merely because its parser accepts new syntax. Completion requires:
- AST, parser, formatter, checker, and runtime support as applicable.
- Positive and negative conformance fixtures.
- Rust unit and integration tests.
- Rust regression tests for observable runtime behavior.
- Language-reference and conceptual documentation.
- Formal static or dynamic semantics for language-level behavior.
- Diagnostic source spans and stable error codes for rejected programs.
- A changelog entry and migration note.
Phase 0 — specification and conformance foundation
This phase prevents the implementation, documentation, and paper from becoming competing definitions of AGL.
- Create a normative, versioned AGL 0.2 specification.
- Declare Rust the sole language implementation.
- Resolve whether objects are exact or use width/depth structural subtyping.
- Reconcile current CLI names, adapter names, environment variables, and defaults.
- Define lexical, grammar, name-resolution, typing, and execution behavior separately.
- Add valid and invalid fixture directories with machine-readable expectations.
- Add a conformance runner that checks parsing, diagnostics, lowering, and execution.
- Add stable phase diagnostic codes.
- Add golden source rendering as part of AGL 0.3 diagnostics.
- Define a language-version mechanism and unsupported-version diagnostic.
- Update the paper with a clear historical-versus-current specification boundary.
Acceptance criteria:
cargo test --test conformanceexecutes every fixture.- Each rejected fixture declares the expected diagnostic code.
- Documentation no longer contradicts checker or runtime behavior.
- Language changes cannot merge without updating the versioned specification or explicitly declaring no semantic change.
AGL 0.3 — typed outcomes and language integrity
Named data types
- Add nominal
recorddeclarations while retaining structuralObjvalues. - Define construction, field access, assignability, and serialization for records.
- Preserve aliases as transparent names and document the distinction from records.
- Add duplicate-field, recursive-type, and visibility diagnostics.
Tagged unions and matching
- Add tagged
uniondeclarations with unit and record variants. - Add constructors for union variants.
- Add
matchstatements with bindings. - Check exhaustiveness and unreachable cases.
- Specify JSON encoding for records and unions.
Structured outcomes and errors
- Add built-in
Result[T, E]alongsideOption[T]. - Represent current task, timeout, assertion, and runtime faults through structured
Failurecatches. - Add tool/adapter-specific, cancellation, and budget failure categories as those runtime layers gain typed failure propagation.
- Keep explicit
match; 0.3 deliberately omits implicit propagation syntax. - Retain
try/catchas a boundary for unexpected execution faults, not ordinary typed failures. - Make retry selection operate on typed error variants.
Diagnostics and developer experience
- Give lexer, parser, resolver, checker, and runtime errors stable codes.
- Render filename, source span, offending line, caret, and concise remediation.
- Detect unreachable statements and unused bindings.
- Provide typo suggestions for declarations, fields, and enum/union variants.
Acceptance criteria:
- A task can return a typed domain error without throwing a runtime exception.
- Every
matchover a closed union is statically exhaustive. - Record and union values round-trip through JSON with a documented stable encoding.
- Existing 0.2 programs either continue to run or receive an automated migration diagnostic.
AGL 0.4 — agent contracts, effects, and evaluation
Effect and capability system
- Define built-in effects such as
model,network,filesystem,external_read,external_write,secret, andhuman. - Let tasks and tools declare required effects.
- Infer effects through pipeline calls and check that callers permit them.
- Add user-defined capability names without allowing them to weaken built-in safety rules.
- Surface declared effect sets in lowered output and the language documentation.
- Include inferred effect sets in generated package/API documentation when documentation generation lands in 0.6.
Retry and idempotency safety
- Declare tasks and tools as pure, idempotent, keyed-idempotent, or non-idempotent.
- Reject unsafe retry policies on side-effecting non-idempotent tasks and agent tools.
- Support retry predicates over typed failures.
- Add bounded exponential backoff and jitter policies with deterministic test control.
- Attach stable invocation and idempotency keys to traces and adapter calls.
Provider-independent agents
- Separate source-level agent requirements from deployment bindings.
- Express capability, context, tool, latency, and quality requirements in source.
- Move provider, model, endpoint, and reasoning settings into deployment configuration.
- Keep an explicit source-level model override as a documented escape hatch.
- Validate deployment configuration against agent requirements before execution.
Resource budgets
- Add scoped budgets for time, tokens, cost, tool calls, retries, and concurrency.
- Define reservation and accounting semantics for parallel work.
- Return typed budget-exhaustion outcomes.
- Record provider usage and estimated/actual cost in traces.
Evaluation
- Extend
testwith deterministic model/tool mocks and recorded replay. - Add dataset-driven
evaldeclarations. - Support schema, predicate, latency, cost, and semantic-grader assertions.
- Run repeated trials and report distributions for nondeterministic evaluations.
- Compare evaluation results against a checked-in baseline in CI.
Acceptance criteria:
- The checker rejects retrying an unkeyed external write.
- The same source program can run against multiple provider bindings without modification.
- A pipeline cannot exceed a declared budget without a typed, traceable outcome.
- Evaluation regressions can fail CI without making ordinary unit tests nondeterministic.
AGL 0.5 — structured concurrency and durable execution
Async runtime and cancellation
- Replace abandoned timeout threads with an async execution engine.
- Propagate cooperative cancellation through pipelines, tasks, tools, and adapters.
- Define scoped deadlines and cleanup behavior.
- Ensure every spawned operation is awaited, cancelled, or durably detached by explicit policy.
Concurrency language
- Generalize parallel branches beyond direct task calls where semantics remain clear.
- Add bounded
parallel mapwith stable result ordering. - Add
racewith explicit winner and loser-cancellation semantics. - Add fail-fast and collect-all policies.
- Add provider/tool concurrency groups and rate limits.
- Define nested concurrency-budget behavior.
Durable workflows
- Assign stable execution and invocation identities.
- Introduce an event-store abstraction and a local SQLite implementation.
- Checkpoint completed operations and pipeline state.
- Resume after process failure without repeating completed side effects.
- Record nondeterministic model, tool, time, and external-input events for replay.
- Detect incompatible source or deployment changes during resume.
- Add retention, redaction, and schema-migration policies for stored histories.
Human interaction
- Add typed approval/input suspension points.
- Resume suspended workflows through CLI and embedding APIs.
- Define approval expiry, rejection, delegation, and audit behavior.
Acceptance criteria:
- Timeout and race losers stop cooperatively without continuing billable work when the provider supports cancellation.
- A killed process resumes from its last durable event without repeating a completed external write.
- Replay can reproduce orchestration decisions without contacting providers.
- Human approval is a persisted state transition rather than a blocked process.
AGL 0.6 — modules, tooling, and ecosystem
Modules and visibility
- Add imports, qualified names, and explicit public/private declarations.
- Specify deterministic local module resolution and cycle diagnostics.
- Support separate compilation and cache validated module interfaces.
- Add documentation generation from exported declarations.
Packages and compatibility
- Add an AGL package manifest with language and package versions.
- Support local-path and pinned Git dependencies first.
- Add a deterministic lockfile and content verification.
- Define semantic-version compatibility rules for exported AGL APIs.
- Defer a public package registry until real reuse patterns justify it.
Language tooling
- Publish a tree-sitter grammar or equivalent incremental parser integration.
- Implement an LSP with diagnostics, hover, completion, definition, references, and rename.
- Add canonical formatting and format-on-save support.
- Add a machine-readable compiler protocol for editor and build-tool integration.
- Provide shell completion and structured CLI output.
Stable embedding and extension API
- Define versioned Rust traits and data contracts for hosts, task handlers, tools, event stores, policy resolvers, and graders.
- Add compatibility tests for extension implementations.
- Keep compile-time Rust registration as the default native extension model.
- Provide native extension migration guidance; the subprocess bridge has been removed.
- Evaluate a process-isolated WASI component boundary only after the native contracts stabilize.
Acceptance criteria:
- A multi-module application type-checks incrementally and has reproducible dependencies.
- Editors can provide precise incremental diagnostics and navigation.
- Public package compatibility can be checked without executing the package.
- Extension API upgrades have explicit compatibility and migration tests.
Cross-cutting security and provenance
These requirements apply throughout phases 0.3–0.6 rather than being deferred to one release.
- Redact secrets from diagnostics, traces, event histories, and evaluation artifacts.
- Restrict tools by declared capabilities and deployment policy.
- Track model, prompt, tool, source, and human-approval provenance.
- Add configurable network-host and filesystem-path allowlists.
- Make external writes and approval requirements visible in static summaries.
- Define trust boundaries for native and future process-isolated extensions.
Deliberate non-goals
- General mutable state, classes, inheritance, or arbitrary metaprogramming.
- Provider-specific prompt syntax in the core language.
- Transparent retries of unknown side effects.
- Unbounded implicit concurrency.
- Dynamic in-process Rust library loading across unstable ABIs.
- A public package registry before modules and compatibility rules mature.
- Replacing host languages for tool implementation or user-interface code.
Release gates for every phase
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --locked
cargo bench --bench runtime
cargo package --locked
Provider-related releases additionally require both manually triggered live-provider smoke tests. Durable-runtime releases require crash/resume, duplicate-side-effect, cancellation, and replay integration tests.
Contributing
This guide explains how to extend AgentLang — adding language features, new tasks, or adapter changes.
Development workflow
-
Edit the Rust source in
src/; it is the normative implementation. -
Format and run the native suite:
cargo fmt --all -- --check cargo test -
Run a representative example:
cargo run -- examples/blog.agent blog_post \ --input '{"topic":"agent memory patterns"}' -
Run the retry/fallback example to exercise the failure path:
cargo run -- examples/reliability.agent resilient_brief \ --input '{"topic":"api-status","fail_count":5}' -
Check the largest language fixture and run its test blocks:
cargo run -- examples/showcase_all_features.agent --check cargo run -- examples/showcase_all_features.agent --test -
If you changed DSL or runtime semantics, update the relevant docs (see below).
Building the documentation
Documentation uses mdBook 0.5.4, matching the documentation CI workflow:
cargo install mdbook --version 0.5.4 --locked
mdbook build
mdbook serve --open
Run these commands from the repository root. book.toml configures the book,
docs/SUMMARY.md defines chapter navigation, and generated HTML goes into
book/. Add new pages to the summary so mdBook renders them. Pull requests
build the book; pushes to main publish it through GitHub Pages.
Use Markdown admonitions (> [!NOTE], > [!WARNING]) for callouts. Math uses
mdBook's MathJax syntax:
escaped delimiters \\( … \\) for inline equations and \\[ … \\] for
display equations. Preserve TeX backslashes through Markdown escaping, as in
the semantics chapters.
Project layout
src/ -- primary Rust compiler and runtime
adapters/ -- provider clients and native web tools
formatter.rs -- canonical source and lowered pipeline formatters
extension.rs -- native extension contracts
examples/
*.agent -- runnable example programs
docs/ -- this documentation
Extending the language
Adding a new syntax feature touches every layer. Update all of these:
| File | What to change |
|---|---|
src/ast.rs | Add or extend AST types |
src/lexer.rs | Add tokens or lexical rules |
src/parser.rs | Add parsing logic and source diagnostics |
src/checker.rs | Add type/effect rules |
src/runtime.rs | Add execution semantics and trace events |
docs/reference/language.md | Update syntax reference |
docs/reference/runtime.md | Update execution phase docs |
docs/advanced/semantics.md | Update formal rules |
Adding a new task
-
Declare the task signature in a
.agentfile:task my_task(input: String) -> Obj{result: String} {} -
Register a Rust handler in a
Registry:registry.register("my_task", |args, _agent| { Ok(serde_json::json!({"result": args["input"]})) }); -
Add or update an example in
examples/. -
Document the task in
docs/reference/examples.mdanddocs/reference/adapters.mdif it has live behavior.
Adapter changes
The native provider clients live in src/adapters/; native web tools live in src/adapters/tools.rs.
Guidelines:
- Keep adapter modules dependency-light.
- Wrap all external errors with clear, user-readable messages.
- Never log or surface secrets in error messages or stack traces.
Style guidelines
- Follow
rustfmtand Clippy for Rust code; use descriptive public API names and explicit error types. - Keep changes small and composable — prefer explicit errors over silent fallbacks.
- Keep docs synchronized with behavior changes.
- Follow Conventional Commit style:
feat:,fix:,docs:prefixes with an imperative, concise subject.
Commit and PR checklist
Before opening a PR:
- At least one happy-path example runs correctly
- At least one failure-path example runs correctly (if relevant)
-
--testpasses onshowcase_all_features.agent - Docs updated for any DSL/runtime/adapter changes
- No secrets in source, examples, or docs