Concurrency & The Agentic Process Model

Turn's concurrency model adapts Erlang's actor model specifically for the agentic domain. Every agent is an isolated process with its own environment, context, memory, and mailbox. Actors communicate by sending messages. They cannot share state.

This model makes multi-agent systems composable, fault-tolerant, and scalable by construction.


Every Agent Is a Process

A Turn process runs as an isolated tuple of state:

  • Its own environment (let bindings)
  • Its own tripartite context window (isolated, token-bounded P0/P1/P2)
  • Its own working memory (O(1) isolated key-value store via remember)
  • Its own mailbox (asynchronous message queue)
  • Its own PID (process identifier, available as self)

No two processes share any of these. Coordination happens exclusively through message passing.


spawn : Creating Child Agents

syntax
let pid = spawn turn() { return 0; };

Spawns a new concurrent agent process. Returns its PID immediately. The spawned process runs concurrently on the VM scheduler.

basic_spawn.tn
// Spawn a child agent that processes a task
let analyst = spawn turn() {
  let result = infer Insight {
      "Analyze this business dataset.";
  };
  return result;
};

call("echo", "Agent spawned with PID: " + analyst);

send / receive : Message Passing

Agents communicate by sending values to each other's mailboxes:

syntax
send pid, value;    // Non-blocking, puts message in pid's mailbox
let msg = receive;  // Blocks until a message arrives
pipeline.tn
// Spawn a specialized analyst agent
let analyst = spawn turn() {
  let query = receive;
  return infer Analysis { query; };
};

// Spawn a writer agent that receives analyst output
let writer = spawn turn() {
  let analysis = receive;
  return infer Report { "Draft an executive summary from: " + analysis["summary"]; };
};

// Orchestrate: send query to analyst, route result to writer
send analyst, "Q4 performance data: revenue up 18%, churn up 3%";
let analysis_result = receive;

send writer, analysis_result;
let report = receive;

call("echo", report);

NOTE

receive blocks the current process until a message arrives. This is not a thread block. The VM yields to its scheduler, and other agents continue running while the receiving process waits.


spawn_link : Linked Failure Propagation

spawn_link creates a bidirectional link between the parent and child process. When a linked child exits (normally or abnormally), the parent receives an exit signal in its mailbox.

spawn_link.tn
struct AnalysisTask { ticker: Str, context: Str };

let worker = spawn_link turn(t: AnalysisTask) {
  let result = infer Analysis {
      "Analyze " + t.ticker + " given: " + t.context;
  };
  return result;
};

send worker, AnalysisTask { ticker: "NVDA", context: "Q4 earnings beat by 12%" };

let msg = receive;
if msg["type"] == "exit" {
  if msg["reason"] == "normal" {
      call("echo", "Result: " + msg["result"]);
  } else {
      call("echo", "Worker failed: " + msg["reason"]);
  }
}

When a linked process exits with an error, its linked partners receive an EXIT message. You can handle it or let it propagate, enabling Erlang-style supervisor trees.

NOTE

spawn creates an unlinked process. If it crashes, the parent is not notified. Use spawn_link whenever you need fault isolation guarantees.


spawn_each : Concurrent List Processing

spawn_each is Turn's native scatter/gather primitive. It delegates each item in a list to a concurrent micro-actor and returns when all actors complete. This is Turn's replacement for imperative for loops: iteration is delegation.

syntax
spawn_each(list, turn(item: Type) { ... });
concurrent_analysis.tn
struct Signal { ticker: Str, recommendation: Str, conviction: Num };

let tickers = ["NVDA", "TSLA", "AAPL", "MSFT"];

spawn_each(tickers, turn(ticker: Str) {
  context.append("You are a quant analyst. Evaluate: " + ticker);
  let signal = infer Signal {
      "Give a buy/sell/hold recommendation with conviction score.";
  };
  call("echo", ticker + ": " + signal.recommendation + " (" + signal.conviction + ")");
});

Each ticker is analyzed by a separate concurrent actor. All four run in parallel.


A Full Multi-Agent Example

The following is a simplified version of Turn's canonical boardroom example demonstrating a swarm of specialized agents collaborating on a shared task:

boardroom.tn
struct BoardMemo { recommendation: Str, vote: Str, certainty: Num };

let proposal = "Acquire a 22-person AI startup for $12M to accelerate our inference roadmap.";
let parent = self;

// Spawn three specialist agents as linked actors
let cfo_pid = spawn_link turn() {
  context.system("You are the CFO. Evaluate financial risk.");
  let memo = infer BoardMemo { "Evaluate this motion: " + proposal; };
  send parent, memo;
};

let cto_pid = spawn_link turn() {
  context.system("You are the CTO. Evaluate technical feasibility.");
  let memo = infer BoardMemo { "Evaluate this motion: " + proposal; };
  send parent, memo;
};

let cmo_pid = spawn_link turn() {
  context.system("You are the CMO. Evaluate market opportunity.");
  let memo = infer BoardMemo { "Evaluate this motion: " + proposal; };
  send parent, memo;
};

// Collect votes from the mailbox
let cfo_memo = receive;
let cto_memo = receive;
let cmo_memo = receive;

// Aggregate
call("echo", "CFO: " + cfo_memo["vote"] + " (certainty: " + cfo_memo["certainty"] + ")");
call("echo", "CTO: " + cto_memo["vote"] + " (certainty: " + cto_memo["certainty"] + ")");
call("echo", "CMO: " + cmo_memo["vote"] + " (certainty: " + cmo_memo["certainty"] + ")");

Each agent runs concurrently as an isolated actor, maintains its own context, performs its own inference, and sends a typed BoardMemo back to the parent via the mailbox. The orchestrating process collects results without any shared state.


gather : Collecting Results from a Swarm

spawn_each scatters work across concurrent actors. gather is the other half: it collects their results back in one blocking call, in the exact order the PIDs were created.

syntax
let pids    = spawn_each(list, turn(item: Type) { ... });
let results = gather pids;

gather is a native VM primitive, not a library function. It inspects the process mailbox for ExitSignal messages from each PID in the list, yields to the scheduler until all of them have resolved, then assembles the result list in input order and returns it atomically.

The Scatter / Gather Pattern

This is the canonical Turn idiom for processing a large batch of items in parallel. It is the language-native equivalent of Promise.allSettled in JavaScript or asyncio.gather in Python, with no boilerplate required:

invoice_reconciliation.tn
struct Invoice { id: Num, status: Str, matched: Bool };

let invoice_ids = [1001, 1002, 1003, 1004, 1005];

// Scatter: every invoice gets its own agent
let agents = spawn_each(invoice_ids, turn(id: Num) {
  context.system("You are an invoice reconciliation agent.");
  let result = infer Invoice {
      "Reconcile invoice #" + id + " against the purchase order database.";
  };
  return result;
});

// Gather: collect all results once every agent completes
let invoices = gather agents;

call("echo", "All " + invoices + " invoices reconciled.");

Error Isolation (allSettled semantics)

If a child agent throws an unhandled error, gather does not propagate that exception to the orchestrator. Instead, the failed agent's error message is returned as its slot in the results list. Every other agent's result is preserved:

resilient_batch.tn
let docs = ["doc_1.pdf", "corrupt_file", "doc_3.pdf"];

let workers = spawn_each(docs, turn(path: Str) {
  if path == "corrupt_file" {
      throw "Cannot parse: " + path;
  }
  return infer Summary { "Summarize document at: " + path; };
});

let results = gather workers;
// results[0] => Summary { ... }         (ok)
// results[1] => "Cannot parse: corrupt_file"   (error string, not a crash)
// results[2] => Summary { ... }         (ok)

This is the correct behavior for agentic systems at scale. A single failed document should never abort 9,999 others. gather guarantees that you always get back a list of length equal to the input, with errors as first-class values.

gather vs Manual receive Loops

Manual receive loopgather
CodeWrite loop, track PIDs manuallyOne keyword
OrderNon-deterministic (first to arrive)Deterministic (matches input order)
ErrorsCrash propagates unless handled explicitlyallSettled: errors are values
Use caseStreaming, partial resultsComplete batch collection

Use receive when you want to process results as they arrive. Use gather when you need the full result set, ordered and fault-tolerant, before proceeding.


Next Steps