Introduction
Patchwork is a language for orchestrating computation that mixes deterministic code with non-deterministic LLM "thinking." This book explains how the interpreter executes Patchwork programs and communicates with language models.
The Core Idea
Patchwork programs interleave two kinds of computation:
- Deterministic blocks - Traditional code that always produces the same output: variables, loops, file I/O, shell commands
- Think blocks - Prompts sent to an LLM, whose output is non-deterministic
The interesting part: a think block can trigger deterministic code (via tool calls), and that code might itself contain think blocks. This creates a recursive interplay between the interpreter and the LLM.
fun analyze_document(doc) {
var category = think {
Categorize this document: $doc
Options: RECEIPT, CONTRACT, PERSONAL
}
if category == "RECEIPT" {
var amount = think {
Extract the dollar amount from: $doc
}
print("Receipt for $amount")
}
}
Why This Matters
This execution model enables:
- Auditability - You can trace exactly what decisions the LLM made and why
- Composition - Deterministic scaffolding with LLM "escape hatches" where judgment is needed
- Recursion - LLM decisions can trigger further LLM decisions, nested arbitrarily deep
Architecture Overview
The interpreter has several key components:
graph TD
Code[Patchwork Code] --> Parser
Parser --> AST
AST --> Evaluator
Evaluator --> Runtime[Runtime Environment]
Evaluator -->|think block| Agent
Agent -->|ThinkRequest| LLM[LLM Session]
LLM -->|ThinkResponse| Agent
Agent --> Evaluator
- Runtime - Manages variable scopes and execution context
- Evaluator - Walks the AST, executing expressions and statements
- Agent - Bridges the synchronous interpreter to async LLM sessions
- ACP Proxy - Integrates Patchwork into the Agent Communication Protocol
What's in This Book
- The Value System - Runtime values: strings, numbers, arrays, objects
- The Runtime - Variable scopes and execution environment
- An Example Program - A concrete program to ground the concepts
- The Evaluator - How expressions and statements execute
- Think Blocks - The core innovation: blocking on LLM responses
- The Agent - Managing concurrent LLM sessions
- The ACP Proxy - Protocol integration
- Nested Think Blocks - Deep dive into recursive execution
The Value System
Patchwork has a simple, JSON-compatible type system. All runtime values are represented by the Value enum in crates/patchwork-eval/src/value.rs.
Value Types
#![allow(unused)] fn main() { pub enum Value { Null, String(String), Number(f64), Boolean(bool), Array(Vec<Value>), Object(HashMap<String, Value>), } }
Like JavaScript, Patchwork uses f64 for all numbers - there's no integer/float distinction.
Type Hierarchy
graph TD
Value --> Null
Value --> String
Value --> Number
Value --> Boolean
Value --> Array
Value --> Object
Array -->|contains| Value
Object -->|contains| Value
Arrays and objects can contain any value type, including nested arrays and objects.
JSON Interoperability
Values convert seamlessly to and from JSON:
#![allow(unused)] fn main() { // Parse JSON string into Value let value = Value::from_json(r#"{"name": "test", "count": 42}"#)?; // Convert Value to JSON string let json = value.to_json(); // Pretty-printed }
This is used by the json() and cat() builtin functions:
var data = json(read("config.json")) // Parse file as JSON
var output = cat(data) // Serialize back to JSON string
Type Coercion
Values support coercion to strings and booleans for use in string interpolation and conditionals.
String Coercion (to_string_value)
| Type | Result |
|---|---|
| Null | "null" |
| String | itself |
| Number | formatted (integers without .0) |
| Boolean | "true" or "false" |
| Array | comma-separated elements |
| Object | "[object Object]" |
Boolean Coercion (to_bool)
| Type | Truthy | Falsy |
|---|---|---|
| Null | - | always |
| String | non-empty | empty |
| Number | non-zero, non-NaN | 0, NaN |
| Boolean | true | false |
| Array | non-empty | empty |
| Object | always | - |
This powers conditionals and loops:
var items = json(read("data.json"))
if items { // truthy if non-empty array
for var item in items {
print(item.name)
}
}
Implementation Notes
The Value type is designed to be:
- Clone-friendly - Values are cloned when assigned to variables
- Debug-friendly - Implements
Debugfor tracing - Comparable - Implements
PartialEqfor equality checks
See crates/patchwork-eval/src/value.rs for the full implementation.
The Runtime
The Runtime struct in crates/patchwork-eval/src/runtime.rs manages the execution environment: variable bindings, working directory, and output redirection.
Structure
#![allow(unused)] fn main() { pub struct Runtime { scopes: Vec<HashMap<String, Value>>, working_dir: PathBuf, print_sink: Option<PrintSink>, } }
Variable Scopes
Patchwork uses lexical scoping with a scope stack. Each block creates a new scope; variables in inner scopes shadow outer ones.
graph TB
subgraph "Scope Stack"
S0["Global Scope<br/>x = 1"]
S1["Block Scope<br/>x = 2, y = 10"]
S2["Inner Block Scope<br/>z = 100"]
end
S2 --> S1
S1 --> S0
Scope Operations
| Method | Description |
|---|---|
push_scope() | Enter a block - push new empty scope |
pop_scope() | Leave a block - pop current scope |
define_var(name, value) | Create variable in current scope |
get_var(name) | Look up variable (inner to outer) |
set_var(name, value) | Update existing variable |
Variable lookup walks from innermost to outermost scope:
#![allow(unused)] fn main() { pub fn get_var(&self, name: &str) -> Option<&Value> { for scope in self.scopes.iter().rev() { if let Some(value) = scope.get(name) { return Some(value); } } None } }
Shadowing Example
var x = 1
{
var x = 2 // shadows outer x
print(x) // prints 2
}
print(x) // prints 1 (outer x restored)
Working Directory
The runtime tracks a working directory for file operations and shell commands:
#![allow(unused)] fn main() { pub fn working_dir(&self) -> &PathBuf { &self.working_dir } pub fn set_working_dir(&mut self, dir: PathBuf) { self.working_dir = dir; } }
This is used by:
read(path)andwrite(path, content)builtins- Shell command execution (
$ ls, etc.)
Relative paths are resolved against this directory, not the process CWD.
Print Sink
By default, print() writes to stdout. But the runtime supports redirecting output through a channel:
#![allow(unused)] fn main() { pub type PrintSink = Sender<String>; pub fn set_print_sink(&mut self, sink: PrintSink) { self.print_sink = Some(sink); } pub fn print(&self, message: String) -> Result<(), String> { if let Some(ref sink) = self.print_sink { sink.send(message).map_err(|e| /* ... */) } else { println!("{}", message); Ok(()) } } }
This is critical for ACP integration - print output needs to be sent as SessionNotification::AgentMessageChunk messages rather than going to stdout. The ACP proxy sets up a channel and forwards messages:
sequenceDiagram
participant E as Evaluator
participant R as Runtime
participant C as Channel
participant P as ACP Proxy
participant Client
E->>R: print("Hello")
R->>C: send("Hello")
C->>P: recv()
P->>Client: AgentMessageChunk
Interpreter Integration
The Interpreter struct wraps the runtime and optionally an agent handle:
#![allow(unused)] fn main() { pub struct Interpreter { runtime: Runtime, agent: Option<AgentHandle>, } }
The interpreter provides the entry point for evaluation:
#![allow(unused)] fn main() { impl Interpreter { pub fn eval(&mut self, code: &str) -> Result<Value> { // Parse code, execute program } } }
See crates/patchwork-eval/src/interpreter.rs for the full implementation.
The Threading Model
Patchwork's architecture involves multiple threads and async tasks working together. Before diving into execution details, it helps to have a clear map of what these components are, when they exist, and why they're named what they are.
Overview
graph TD
subgraph "Patchwork Process"
subgraph "Main Thread"
P[Proxy Message Loop]
end
subgraph "Async Runtime"
RA[Redirect Actor]
TH1[Think Handler 1]
TH2[Think Handler 2]
end
subgraph "Spawned Threads"
E1[Evaluator Thread]
E2[Evaluator Thread]
end
end
subgraph "Successor Process"
S[Claude Code / LLM Agent]
end
P -->|spawns| E1
P -->|spawns| E2
E1 -->|ThinkRequest| RA
E2 -->|ThinkRequest| RA
RA -->|routes to| TH1
RA -->|routes to| TH2
TH1 <-->|ACP| S
TH2 <-->|ACP| S
The system has two OS processes:
- Patchwork process: Runs the proxy, evaluator threads, and async tasks
- Successor process: An external agent (like Claude Code) that handles LLM communication
Within the Patchwork process, there are three kinds of execution contexts:
- Main thread: Runs the ACP message loop
- Spawned threads: One per active Patchwork evaluation (synchronous)
- Async tasks: The redirect actor and think handlers (in the tokio runtime)
Naming Glossary
| Term | What It Is | Why That Name |
|---|---|---|
| Successor | The next agent in the ACP protocol chain | ACP terminology—each proxy forwards requests to its "successor" in the chain |
| Redirect Actor | Long-lived async task that routes messages | "Actor" = task with message inbox; "redirect" = its job is routing messages to the right handler |
| Evaluator Thread | Synchronous thread running the Patchwork interpreter | Runs eval_block, eval_expr, etc. |
| Think Handler | Async task managing one think block's LLM conversation | Handles the back-and-forth with the successor for a single think block |
Why "Successor"?
In ACP (Agent Communication Protocol), agents form a chain. Each agent can forward requests to the next agent in the chain—its "successor." The Patchwork proxy sits between the editor and Claude Code:
Editor → Patchwork Proxy → Claude Code (successor) → LLM
When Patchwork code contains a think block, the proxy creates an LLM session with its successor. The successor handles the actual LLM communication.
Why "Actor"?
The redirect actor follows the actor model pattern:
- It's a long-lived task
- It receives messages via a channel (its "inbox")
- It processes messages in a loop
- It doesn't share mutable state—all communication happens via messages
The redirect actor is the only true actor in the system. Other components are either threads (evaluator), request handlers (proxy), or external processes (successor).
Component Lifetimes
| Component | Created When | Destroyed When | How Many |
|---|---|---|---|
| Proxy message loop | Process starts | Process exits | 1 |
| Redirect actor | Process starts | Process exits | 1 |
| Evaluator thread | Patchwork prompt detected | Evaluation completes | 1 per active evaluation |
| Think handler | Think block entered | Think block returns | 1 per active think block |
| Successor process | External (already running) | External | 1 (shared) |
The key insight: the proxy and redirect actor live for the entire process lifetime, while evaluator threads and think handlers are created and destroyed as code executes.
gantt
title Component Lifetimes
dateFormat X
axisFormat %s
section External
Successor Process :active, 0, 100
section Process Lifetime
Proxy Message Loop :0, 100
Redirect Actor :0, 100
section Per Evaluation
Evaluator Thread 1 :10, 40
Evaluator Thread 2 :50, 80
section Per Think Block
Think Handler A :15, 35
Think Handler B :55, 75
The Sync/Async Boundary
The evaluator is deliberately synchronous. This keeps the execution model simple—no async/await in user-facing semantics, natural blocking on think blocks, easy reasoning about variable scopes and control flow.
But LLM communication is inherently async. The agent layer bridges these worlds:
sequenceDiagram
participant E as Evaluator Thread<br/>(sync)
participant A as Agent Layer<br/>(async boundary)
participant S as Successor<br/>(async)
Note over E: Synchronous code execution
E->>E: eval_expr, eval_statement...
E->>A: ThinkRequest(prompt)
Note over E: Thread blocks here
Note over A: Async operations
A->>S: session/new
S-->>A: session_id
A->>S: prompt(text)
loop Streaming
S-->>A: notification (chunk)
end
S-->>A: PromptResponse
A->>E: ThinkResponse::Complete(value)
Note over E: Thread unblocks
E->>E: Continue execution
The evaluator thread blocks on a standard std::sync::mpsc channel. Meanwhile, the agent layer (running in the async runtime) handles all the async communication with the successor. When the response is ready, it sends back through the channel, unblocking the evaluator.
Why Synchronous Evaluation?
Several design choices led to synchronous evaluation:
- Simpler mental model: Users think about their code executing line by line, not as async tasks
- Natural blocking: Think blocks conceptually "pause" execution until the LLM responds
- Scope clarity: Variable lifetimes are straightforward—no async captures to reason about
- Exception propagation: Rust's
?operator works naturally through the call stack
The async complexity is isolated in the agent layer, invisible to both the language user and most of the interpreter code.
Message Flow Summary
Putting it all together, here's how a think block executes:
- Evaluator thread encounters a think block, sends
ThinkRequestto the agent - Agent creates a think handler task, pushes it onto the redirect actor's stack
- Think handler opens a session with the successor, sends the prompt
- Successor streams response chunks back as notifications
- Redirect actor routes notifications to the active think handler
- Think handler accumulates the response, sends
ThinkResponse::Completeto the evaluator - Evaluator thread unblocks, continues execution with the result
The redirect actor's stack enables nested think blocks—when an inner think starts, it pushes onto the stack and receives messages until it completes. This is covered in detail in Nested Think Blocks.
An Example Program
Before diving into evaluation mechanics, let's look at a concrete Patchwork program. This grounds the concepts in something tangible.
The Program
fun code_review_assistant() {
var task = "Add OAuth support to the login system"
var files = $ls -1 src/auth/
var analysis = think {
The user wants to: ${task}
Relevant files in src/auth/:
${files}
Please identify:
1. Which files need modification
2. What new files should be created
3. Security considerations
}
for file in files {
if file.endsWith(".rs") {
var content = read("src/auth/${file}")
var review = think {
Review this file for OAuth readiness:
${content}
}
print("${file}: ${review}")
}
}
return analysis
}
What Happens at Runtime
When this function executes, several things interleave:
sequenceDiagram
participant E as Evaluator
participant R as Runtime
participant S as Shell
participant L as LLM
E->>R: Define 'task' = "Add OAuth..."
E->>S: Execute ls -1 src/auth/
S-->>E: ["auth.rs", "login.rs", ...]
E->>R: Define 'files' = [...]
E->>L: Think: analyze task
L-->>E: Analysis text
E->>R: Define 'analysis' = ...
loop For each file
E->>R: Check file.endsWith(".rs")
E->>R: read() file contents
E->>L: Think: review file
L-->>E: Review text
E->>R: print() review
end
E-->>E: Return analysis
Key Observations
Variables are simple - var task = "..." creates a binding in the current scope. The evaluator stores Value::String in the runtime's scope stack.
Shell commands return values - $ls -1 src/auth/ executes ls and captures stdout. With -1, the evaluator splits the output into an array of lines.
Think blocks block - When the evaluator hits think { ... }, it:
- Interpolates variables into the prompt text
- Sends a
ThinkRequestto the agent - Blocks waiting for
ThinkResponse - Returns the LLM's response as a string
Control flow is standard - for and if work like any language. The evaluator pushes a new scope for the loop body, binds the iteration variable, evaluates the body, then pops the scope.
Builtins are synchronous - read() and print() are builtin functions that the evaluator handles directly—no LLM involvement.
The Recursive Potential
This example only has one level of think blocks. But imagine the LLM's response triggered a tool call that ran more Patchwork code:
var result = think {
Analyze this task: ${task}
You can use:
- analyze_file(path) to deeply analyze a specific file
}
If analyze_file itself contains a think block, we get nested evaluation:
Evaluator → Agent → LLM → tool call → Evaluator → Agent → LLM → ...
This recursive interplay is what makes the architecture interesting, and it's why the agent needs careful channel management to avoid deadlock.
Next: The Evaluator
Now that you've seen what a program looks like at runtime, the next chapter explains how the evaluator actually walks the AST and executes each construct.
The Evaluator
The evaluator walks the AST and executes each node. It's implemented in crates/patchwork-eval/src/eval.rs.
Synchronous Evaluation Model
The evaluator uses a synchronous model where all functions return Result<Value, Error>:
#![allow(unused)] fn main() { pub fn eval_expr( expr: &Expr, runtime: &mut Runtime, agent: Option<&AgentHandle>, ) -> Result<Value, Error> }
Even think blocks—which communicate with an external LLM—block the calling thread until the response arrives. This keeps the evaluation logic simple: no async/await, no futures, just straightforward recursive descent.
Core Functions
The evaluator has three main entry points:
| Function | Purpose |
|---|---|
eval_block | Evaluate a block of statements, returns last value |
eval_statement | Evaluate a single statement |
eval_expr | Evaluate an expression, returns a Value |
graph TD
eval_block --> eval_statement
eval_statement --> eval_expr
eval_statement --> eval_block
eval_expr --> eval_expr
eval_expr --> eval_block
eval_expr -->|think| eval_think_block
Evaluating Blocks
A block creates a new scope, evaluates each statement, and returns the last value:
#![allow(unused)] fn main() { pub fn eval_block( block: &Block, runtime: &mut Runtime, agent: Option<&AgentHandle>, ) -> Result<Value, Error> { runtime.push_scope(); let mut result = Value::Null; for stmt in &block.statements { result = eval_statement(stmt, runtime, agent)?; } runtime.pop_scope(); Ok(result) } }
The push_scope/pop_scope calls ensure variables declared in the block don't leak out.
Evaluating Statements
Statements dispatch on the AST node type:
#![allow(unused)] fn main() { pub fn eval_statement(stmt: &Statement, ...) -> Result<Value, Error> { match stmt { Statement::VarDecl { pattern, init } => { ... } Statement::Expr(expr) => eval_expr(expr, runtime, agent), Statement::If { condition, then_block, else_block } => { ... } Statement::ForIn { var, iter, body } => { ... } Statement::While { condition, body } => { ... } Statement::Return(expr) => { ... } ... } } }
Variable Declaration
#![allow(unused)] fn main() { Statement::VarDecl { pattern, init } => { let value = match init { Some(expr) => eval_expr(expr, runtime, agent)?, None => Value::Null, }; bind_pattern(pattern, value, runtime)?; Ok(Value::Null) } }
The pattern can be a simple identifier (var x = 1) or destructuring (var {name, age} = person).
Control Flow
if evaluates the condition and picks a branch:
#![allow(unused)] fn main() { Statement::If { condition, then_block, else_block } => { let cond_value = eval_expr(condition, runtime, agent)?; if cond_value.to_bool() { eval_block(then_block, runtime, agent) } else if let Some(else_blk) = else_block { eval_block(else_blk, runtime, agent) } else { Ok(Value::Null) } } }
for iterates over arrays or string lines:
#![allow(unused)] fn main() { Statement::ForIn { var, iter, body } => { let iter_value = eval_expr(iter, runtime, agent)?; let items = match iter_value { Value::Array(arr) => arr, Value::String(s) => s.lines().map(|l| Value::String(l.to_string())).collect(), other => return Err(Error::Runtime(...)), }; for item in items { runtime.push_scope(); runtime.define_var(var, item)?; eval_block(body, runtime, agent)?; runtime.pop_scope(); } Ok(Value::Null) } }
Evaluating Expressions
Expressions return values. The big match covers all expression types:
#![allow(unused)] fn main() { pub fn eval_expr(expr: &Expr, ...) -> Result<Value, Error> { match expr { Expr::Identifier(name) => runtime.get_var(name).cloned()..., Expr::Number(s) => Ok(Value::Number(s.parse()?)), Expr::String(lit) => eval_string_literal(lit, runtime, agent), Expr::True => Ok(Value::Boolean(true)), Expr::Array(items) => { ... } Expr::Object(fields) => { ... } Expr::Binary { op, left, right } => eval_binary(...), Expr::Call { callee, args } => eval_call(...), Expr::Think(prompt_block) => eval_think_block(...), Expr::BareCommand { name, args } => eval_bare_command(...), ... } } }
String Interpolation
Strings can contain interpolations like "Hello ${name}":
#![allow(unused)] fn main() { fn eval_string_literal(lit: &StringLiteral, ...) -> Result<Value, Error> { let mut result = String::new(); for part in &lit.parts { match part { StringPart::Text(s) => result.push_str(&process_escape_sequences(s)), StringPart::Interpolation(expr) => { let value = eval_expr(expr, runtime, agent)?; result.push_str(&value.to_string_value()); } } } Ok(Value::String(result)) } }
Binary Operations
Binary ops handle arithmetic, comparison, and assignment:
#![allow(unused)] fn main() { fn eval_binary(op: &BinOp, left: &Expr, right: &Expr, ...) -> Result<Value, Error> { // Assignment is special - evaluate RHS, store in LHS if let BinOp::Assign = op { let value = eval_expr(right, runtime, agent)?; if let Expr::Identifier(name) = left { runtime.set_var(name, value.clone())?; return Ok(value); } } let left_val = eval_expr(left, runtime, agent)?; let right_val = eval_expr(right, runtime, agent)?; match op { BinOp::Add => { /* number add or string concat */ } BinOp::Sub | BinOp::Mul | BinOp::Div => { /* numeric ops */ } BinOp::Eq | BinOp::NotEq | BinOp::Lt | BinOp::Gt => { /* comparisons */ } BinOp::And | BinOp::Or => { /* logical ops */ } ... } } }
String concatenation happens when either operand is a string:
#![allow(unused)] fn main() { BinOp::Add => match (&left_val, &right_val) { (Value::Number(a), Value::Number(b)) => Value::Number(a + b), (Value::String(a), Value::String(b)) => Value::String(format!("{}{}", a, b)), (Value::String(a), b) => Value::String(format!("{}{}", a, b.to_string_value())), (a, Value::String(b)) => Value::String(format!("{}{}", a.to_string_value(), b)), _ => return Err(...), } }
Builtin Functions
Builtins are handled specially in eval_call:
#![allow(unused)] fn main() { fn eval_builtin(name: &str, args: &[Value], runtime: &Runtime) -> Result<Value, Error> { match name { "print" => { runtime.print(...)?; Ok(Value::Null) } "len" => { /* array/string/object length */ } "read" => { /* read file contents */ } "write" => { /* write file contents */ } "json" => { /* parse JSON string */ } "cat" => { /* serialize to JSON */ } "keys" | "values" => { /* object introspection */ } "typeof" => { /* type name */ } _ => Err(Error::Runtime(format!("Unknown function: {}", name))), } } }
Exception Propagation
Exceptions use Error::Exception(Value) and propagate via Rust's ? operator:
#![allow(unused)] fn main() { // In eval_unary UnOp::Throw => Err(Error::Exception(value)) // Callers automatically propagate: let result = eval_expr(expr, runtime, agent)?; // Exception bubbles up }
This makes the control flow transparent—exceptions use the same mechanism as other errors.
Shell Commands
Bare commands like $ls -1 execute via the system shell:
#![allow(unused)] fn main() { fn eval_bare_command(name: &str, args: &[CommandArg], ...) -> Result<Value, Error> { let mut cmd_args = Vec::new(); for arg in args { match arg { CommandArg::Literal(s) => cmd_args.push(s.to_string()), CommandArg::String(lit) => { let value = eval_string_literal(lit, runtime, agent)?; cmd_args.push(value.to_string_value()); } } } exec_command(name, &cmd_args, runtime) } fn exec_command(name: &str, args: &[String], runtime: &Runtime) -> Result<Value, Error> { let output = Command::new(name) .args(args) .current_dir(runtime.working_dir()) .output()?; if !output.status.success() { return Err(Error::Runtime(...)); } Ok(Value::String(String::from_utf8_lossy(&output.stdout).into_owned())) } }
Commands run in the runtime's working directory, and stdout becomes the return value.
Think Blocks: The Bridge to LLM
Think blocks are where the evaluator meets the agent. This is covered in detail in the next chapter, but the key insight is:
#![allow(unused)] fn main() { Expr::Think(prompt_block) => eval_think_block(prompt_block, runtime, agent) }
The evaluator treats think blocks like any other expression—call a function, get a value back. The complexity of LLM communication is hidden behind eval_think_block.
Think Blocks
Think blocks are where Patchwork code yields control to an LLM. The interpreter sends a prompt and blocks until the LLM responds. This chapter covers the mechanics of that handoff.
The Core Abstraction
A think block looks like any other expression to the evaluator:
#![allow(unused)] fn main() { Expr::Think(prompt_block) => eval_think_block(prompt_block, runtime, agent) }
But inside eval_think_block, something unusual happens: the interpreter thread blocks on a channel, waiting for an external response.
Prompt Interpolation
Before sending to the LLM, the prompt text is interpolated. Variables and expressions inside ${} are evaluated:
#![allow(unused)] fn main() { fn eval_think_block( prompt_block: &PromptBlock, runtime: &mut Runtime, agent: Option<&AgentHandle>, ) -> Result<Value, Error> { let mut prompt_text = String::new(); for item in &prompt_block.items { match item { PromptItem::Text(text) => { prompt_text.push_str(text); } PromptItem::Interpolation(expr) => { let value = eval_expr(expr, runtime, agent)?; prompt_text.push_str(&value.to_string_value()); } PromptItem::Code(block) => { // Embedded code blocks execute but don't add to prompt eval_block(block, runtime, agent)?; } } } // ... send to agent } }
This transforms:
var name = "OAuth"
think {
Analyze the ${name} implementation in these files:
${files}
}
Into a concrete prompt string before it's sent to the LLM.
Channel Architecture
The interpreter uses two different channel types to bridge sync and async worlds:
graph LR
subgraph "Sync Interpreter Thread"
E[Evaluator]
end
subgraph "Async Agent Runtime"
A[Agent]
L[LLM Session]
end
E -->|ThinkRequest| A
A -->|ThinkResponse| E
A <-->|messages| L
| Direction | Channel Type | Why |
|---|---|---|
| Request (Interpreter → Agent) | tokio::mpsc::UnboundedSender | Non-blocking send from sync code |
| Response (Agent → Interpreter) | std::sync::mpsc::Receiver | Blocking receive in sync code |
This asymmetry is deliberate: the interpreter can fire off a request without blocking, but then blocks waiting for the response.
The Request/Response Protocol
ThinkRequest
Sent from interpreter to agent:
#![allow(unused)] fn main() { pub struct ThinkRequest { /// The interpolated prompt text pub prompt: String, /// Variable bindings from the current scope pub bindings: HashMap<String, Value>, /// Expected response type ("string", "json", etc.) pub expect: String, /// Channel to receive responses pub response_tx: mpsc::Sender<ThinkResponse>, } }
The response_tx is created fresh for each think block, so responses are routed back to the correct waiting interpreter. This becomes important with nested think blocks, covered in a later chapter.
ThinkResponse
The agent sends one or more responses:
#![allow(unused)] fn main() { pub enum ThinkResponse { /// LLM wants to execute code (recursive evaluation) Do { index: usize, result_tx: mpsc::SyncSender<String>, }, /// Think block completed Complete { result: Result<Value, String>, }, } }
A typical think block receives exactly one Complete. But if the LLM invokes tools that trigger code execution, the interpreter may receive Do messages first.
Blocking Semantics
The interpreter blocks in a simple loop:
#![allow(unused)] fn main() { // Send request let rx = agent.think(prompt_text, bindings, "string".to_string())?; // Block waiting for responses for response in rx { match response { ThinkResponse::Do { index, result_tx } => { // Recursive evaluation (covered in Nested Think Blocks) let result = /* evaluate child */; result_tx.send(result)?; } ThinkResponse::Complete { result } => { return result.map_err(Error::Runtime); } } } }
The for response in rx loop blocks until the channel closes or Complete arrives.
Sequence Diagram
A simple think block without tool calls:
sequenceDiagram
participant E as Evaluator
participant A as Agent
participant L as LLM
E->>E: Interpolate prompt
E->>A: ThinkRequest(prompt, bindings)
Note over E: Blocked on rx.recv()
A->>L: Send prompt
L-->>A: Response text
A->>E: ThinkResponse::Complete(value)
Note over E: Unblocked, returns value
No Agent Mode
When testing or running without an LLM, agent is None. The evaluator returns a placeholder:
#![allow(unused)] fn main() { if agent.is_none() { let mut result = HashMap::new(); result.insert("__think_prompt".to_string(), Value::String(prompt_text)); return Ok(Value::Object(result)); } }
This lets tests verify that prompt interpolation works without needing a real LLM.
Next: The Agent
The interpreter side is straightforward—send request, block, receive response. The complexity lives in the Agent, which manages LLM sessions and routes messages.
The Agent
The agent bridges the synchronous interpreter to asynchronous LLM sessions. It receives think requests, manages LLM communication, and routes responses back to waiting interpreter threads.
Architecture Overview
graph TD
subgraph "Interpreter Thread (Sync)"
E[Evaluator]
end
subgraph "Agent Runtime (Async)"
H[AgentHandle]
RA[Redirect Actor]
TH[Think Handler]
end
subgraph "Successor Agent"
LLM[LLM Session]
end
E -->|ThinkRequest| H
H -->|unbounded channel| TH
TH -->|session/new| LLM
TH -->|prompt| LLM
LLM -->|notifications| RA
RA -->|route to active| TH
TH -->|ThinkResponse| E
The key insight: the interpreter is synchronous, but LLM communication is async. The agent manages this boundary using channels and a dedicated routing actor.
AgentHandle
The AgentHandle is the interpreter's interface to the agent:
#![allow(unused)] fn main() { #[derive(Clone)] pub struct AgentHandle { tx: UnboundedSender<ThinkRequest>, } impl AgentHandle { pub fn think( &self, prompt: String, bindings: HashMap<String, Value>, expect: String, ) -> Result<mpsc::Receiver<ThinkResponse>, String> { // Create response channel let (response_tx, response_rx) = mpsc::channel(); // Send request (non-blocking) self.tx.send(ThinkRequest { prompt, bindings, expect, response_tx, })?; // Return receiver for blocking Ok(response_rx) } } }
The handle is Clone, so it can be passed to multiple interpreter threads. Each call to think() creates a fresh response channel, so responses route to the correct waiter. This matters for nested think blocks, where multiple think operations may be in flight simultaneously—we'll see how this works in the Nested Think Blocks chapter.
The Redirect Actor
When nested think blocks occur, multiple LLM sessions may be active simultaneously. The redirect actor maintains a stack of active sessions and routes incoming messages to the correct one:
#![allow(unused)] fn main() { async fn redirect_actor(mut rx: UnboundedReceiver<RedirectMessage>) { let mut stack: Vec<Sender<PerSessionMessage>> = vec![]; while let Some(message) = rx.recv().await { match message { RedirectMessage::IncomingMessage(msg) => { // Route to top of stack (innermost active think) if let Some(sender) = stack.last() { sender.send(msg).await?; } } RedirectMessage::PushThinker(sender) => { stack.push(sender); } RedirectMessage::PopThinker => { stack.pop(); } } } } }
This stack-based routing enables nested think blocks: when an outer think triggers code that contains an inner think, the inner one pushes onto the stack and receives messages until it completes.
The Think Message Flow
When a think request arrives, the agent:
- Creates a new session with the successor agent
- Pushes itself onto the redirect stack
- Sends the prompt
- Accumulates the response
- Pops from the stack and returns
#![allow(unused)] fn main() { async fn think_message( cx: JrConnectionCx, prompt: String, expect: String, state: Arc<AgentState>, ) -> ThinkResult { // 1. Augment prompt with type hints let augmented_prompt = augment_prompt_with_type_hint(&prompt, &expect); // 2. Create session with successor let NewSessionResponse { session_id, .. } = cx .send_request_to_successor(NewSessionRequest { ... }) .block_task() .await?; // 3. Push onto redirect stack let (think_tx, mut think_rx) = channel(128); state.redirect_tx.send(RedirectMessage::PushThinker(think_tx))?; // 4. Send prompt and wait for response cx.send_request_to_successor(PromptRequest { session_id, prompt: vec![augmented_prompt.into()], }).await_when_result_received(...); // 5. Accumulate response let mut result_text = String::new(); while let Some(message) = think_rx.recv().await { match message { PerSessionMessage::SessionNotification(n) => { // Accumulate streaming text if let SessionUpdate::AgentMessageChunk(chunk) = n.update { result_text.push_str(&chunk.content.text); } } PerSessionMessage::PromptResponse(_) => break, ... } } // 6. Pop and return state.redirect_tx.send(RedirectMessage::PopThinker)?; extract_response_value(&result_text, &expect) } }
Sequence Diagram
sequenceDiagram
participant E as Evaluator
participant A as AgentHandle
participant R as Redirect Actor
participant T as Think Handler
participant S as Successor
E->>A: think(prompt)
A->>T: ThinkRequest
T->>R: PushThinker(tx)
T->>S: session/new
S-->>T: session_id
T->>S: prompt(session_id, text)
loop Streaming
S-->>R: SessionNotification
R-->>T: AgentMessageChunk
T->>T: Accumulate text
end
S-->>R: PromptResponse(EndTurn)
R-->>T: EndTurn
T->>R: PopThinker
T->>E: ThinkResponse::Complete(value)
The "do" Tool
For nested evaluation, the agent exposes a "do" MCP tool that the LLM can invoke:
#![allow(unused)] fn main() { pub fn create_mcp_server(redirect_tx: UnboundedSender<RedirectMessage>) -> McpServer { McpServer::new() .instructions("Patchwork interpreter tools for recursive evaluation") .tool_fn( "do", "Execute a Patchwork code fragment by index", async move |arg: DoArg, _cx| -> Result<DoResult, Error> { let (result_tx, result_rx) = oneshot::channel(); redirect_tx.send(RedirectMessage::IncomingMessage( PerSessionMessage::DoInvocation(arg, result_tx), ))?; Ok(DoResult { text: result_rx.await?, }) }, ) } }
When the LLM calls do(3), the agent routes this back to the interpreter for evaluation, then returns the result to the LLM.
Response Extraction
The agent augments prompts with type hints and extracts structured responses:
#![allow(unused)] fn main() { fn augment_prompt_with_type_hint(prompt: &str, expect: &str) -> String { match expect { "string" => format!( "{}\n\nRespond with:\n```text\nyour response\n```", prompt ), "json" => format!( "{}\n\nRespond with:\n```json\nyour JSON\n```", prompt ), _ => prompt.to_string(), } } fn extract_response_value(response: &str, expect: &str) -> ThinkResult { // Find code fence with expected type if let Some(value) = extract_code_fence(response, fence_marker) { match expect { "string" => Ok(Value::String(value)), "json" => serde_json::from_str(&value).map(json_to_value), ... } } else { // Fallback: use full response Ok(Value::String(response.to_string())) } } }
This structured extraction lets think blocks return typed values, not just strings.
Why This Design?
Unbounded channels for requests: The interpreter can send without blocking, even if the agent is busy. This prevents deadlock when nested thinks occur.
Standard channels for responses: The interpreter must block anyway, so std::sync::mpsc is simpler than polling.
Stack-based routing: Nested think blocks naturally form a stack. The innermost active session should receive messages; when it completes, the outer one resumes.
MCP for tools: The "do" tool uses MCP protocol, so it integrates with the successor agent's existing tool infrastructure.
Next: The ACP Proxy
The agent handles think blocks, but how does Patchwork code get triggered in the first place? The ACP Proxy chapter explains how Patchwork integrates into the larger agent communication protocol.
The ACP Proxy
The ACP proxy integrates Patchwork into the Agent Communication Protocol chain. It sits between an editor (like Zed) and an agent (like Claude Code), intercepting prompts that contain Patchwork code.
Where It Sits
graph LR
Editor[Editor/IDE] --> Proxy[Patchwork Proxy]
Proxy --> Successor[Claude Code]
Successor --> LLM[LLM]
Proxy -.->|Patchwork code| Interpreter
Interpreter -.->|think blocks| Successor
The proxy intercepts prompt requests. If the prompt contains Patchwork code, the proxy executes it instead of forwarding to the successor. If not, the request passes through unchanged.
Prompt Detection
The proxy recognizes two input forms:
| Pattern | Mode | Example |
|---|---|---|
{ ... } | Block mode | { var x = 1; print(x) } |
$ ... | Shell shorthand | $ ls -la |
#![allow(unused)] fn main() { fn detect_patchwork_input(text: &str) -> Option<String> { let trimmed = text.trim_start(); if trimmed.starts_with('{') { // Block mode - pass through as-is Some(text.to_string()) } else if trimmed.starts_with('$') { // Shell shorthand - wrap in print block let command = trimmed[1..].trim_start(); Some(format!( r#"{{ var output = ($ {}) print("```\n${{output}}```\n") }}"#, command )) } else { None } } }
Shell shorthand automatically wraps the command in a block that captures and prints the output with markdown formatting.
The Critical Spawning Pattern
The proxy must not block when handling a prompt. If it did, incoming responses (including responses to think blocks) wouldn't be dispatched, causing deadlock.
#![allow(unused)] fn main() { async fn handle_prompt( proxy: Arc<Mutex<PatchworkProxy>>, request: PromptRequest, cx: JrRequestCx<PromptResponse>, ) -> Result<(), sacp::Error> { // ... detection logic ... // CRITICAL: Spawn the evaluation as a separate task connection_cx.spawn( run_patchwork_evaluation(proxy, session_id, code, agent_handle, cx) )?; Ok(()) // Return immediately } }
The evaluation runs in a spawned task, freeing the message processing loop to dispatch responses.
Evaluation Flow
sequenceDiagram
participant E as Editor
participant P as Proxy
participant I as Interpreter
participant S as Successor
E->>P: prompt("{ think {...} }")
P->>P: detect_patchwork_input()
P->>P: spawn evaluation task
Note over P: Handler returns immediately
P->>I: eval(code)
I->>P: ThinkRequest
P->>S: session/new
S-->>P: session_id
P->>S: prompt(think text)
S-->>P: response
P-->>I: ThinkResponse::Complete
I-->>P: Value
P->>E: PromptResponse(EndTurn)
Print Forwarding
When Patchwork code calls print(), the output should appear in the editor. The proxy forwards print messages as session notifications:
#![allow(unused)] fn main() { fn forward_prints_to_notifications( rx: std::sync::mpsc::Receiver<String>, connection_cx: &JrConnectionCx, session_id: &str, ) { while let Ok(message) = rx.recv() { let notification = SessionNotification { session_id: session_id.to_string().into(), update: SessionUpdate::AgentMessageChunk(ContentChunk { content: ContentBlock::Text(TextContent { text: message, ... }), }), }; connection_cx.send_notification(notification)?; } } }
This runs in a separate blocking task, forwarding each print() call as it happens.
Session Notification Routing
Notifications from the successor (streaming LLM responses) need to reach the active think block. The proxy routes them to the redirect actor:
#![allow(unused)] fn main() { .on_receive_notification_from_successor({ async move |notification: SessionNotification, _cx| { if let Some(redirect_tx) = proxy.lock().unwrap().redirect_tx() { redirect_tx.send(RedirectMessage::IncomingMessage( PerSessionMessage::SessionNotification(notification), )); } Ok(()) } }) }
This connects the ACP notification stream to the agent's redirect actor (covered in The Agent).
Proxy State
The proxy tracks:
#![allow(unused)] fn main() { struct PatchworkProxy { /// Sessions with active evaluations active_sessions: HashSet<String>, /// Agent handle for think blocks agent_handle: Option<AgentHandle>, /// Redirect channel for session notifications redirect_tx: Option<UnboundedSender<RedirectMessage>>, } }
- Active sessions: Prevents concurrent evaluations on the same session
- Agent handle: Created once at startup, cloned for each evaluation
- Redirect channel: Routes successor notifications to think blocks
Startup Sequence
sequenceDiagram
participant M as main()
participant P as PatchworkProxy
participant A as Agent
participant H as Handler Chain
M->>P: Create proxy state
M->>H: Build handler chain
M->>H: Connect to stdio
H->>A: with_client callback
A->>A: create_agent()
A->>P: set_agent(handle, redirect_tx)
A->>A: Run request_rx loop
Note over H: Ready to handle prompts
The agent is created in the with_client callback, ensuring the connection is established before starting the request loop.
Error Handling
The proxy handles three error cases:
| Error | Response |
|---|---|
| Parse error | invalid_params with message |
| Runtime error | invalid_params with message |
Exception (throw) | internal_error with value |
#![allow(unused)] fn main() { match eval_result { Ok(value) => { cx.respond(PromptResponse { stop_reason: StopReason::EndTurn })?; } Err(EvalError::Exception(value)) => { cx.respond_with_error( sacp::Error::internal_error() .with_data(format!("Patchwork exception: {}", value)), )?; } Err(e) => { cx.respond_with_error( sacp::Error::invalid_params() .with_data(format!("Patchwork error: {}", e)), )?; } } }
Why Spawn?
The spawning pattern deserves emphasis. Consider what happens without it:
- Editor sends
prompt({ think {...} }) - Proxy starts evaluating, hits think block
- Think block sends request to successor, waits for response
- Successor sends response notification
- Deadlock: The notification can't be dispatched because
handle_promptis blocked
By spawning the evaluation, the handler returns immediately, allowing the message loop to dispatch notifications. The evaluation task receives its responses through the redirect actor.
Implementation Details
The proxy is implemented in crates/patchwork-acp/src/main.rs. Key functions:
| Function | Purpose |
|---|---|
detect_patchwork_input | Check if prompt is Patchwork code |
handle_prompt | Intercept prompts, spawn evaluation |
run_patchwork_evaluation | Execute code in spawned task |
forward_prints_to_notifications | Stream print output to editor |
Nested Think Blocks
Think blocks can nest: deterministic code inside a think block might itself contain think blocks. This chapter explains how the system handles this recursive interplay without deadlock.
When Nesting Occurs
Nested think blocks arise when:
- A think block includes code fragments the LLM can execute via the
dotool - That code contains another think block
- The inner think must complete before the outer think can continue
var analysis = think {
Analyze this code and determine what tests to write.
You can run this to see the current tests:
do {
$ ls tests/
}
You can also ask for clarification:
do {
var answer = think {
What testing framework does this project use?
}
print(answer)
}
}
Here the outer think might invoke the inner do blocks, each of which could trigger further think blocks.
The Stack-Based Solution
The redirect actor maintains a stack of active think handlers:
graph TD
subgraph "Redirect Actor State"
S[Stack]
S --> T1[Think Handler 1<br/>outer]
S --> T2[Think Handler 2<br/>inner]
S --> T3[Think Handler 3<br/>innermost]
end
N[Incoming Notification] --> S
S -->|routes to top| T3
When a notification arrives from the successor, it goes to the top of the stack—the innermost active think block. This is correct because:
- The innermost think is the one currently waiting for LLM responses
- Outer thinks are blocked, waiting for their
doinvocations to complete - When the innermost completes, it pops off, and the next one down becomes active
Execution Flow
Here's what happens when nested think blocks execute:
sequenceDiagram
participant E as Evaluator Thread
participant R as Redirect Actor
participant T1 as Think Handler 1
participant T2 as Think Handler 2
participant S as Successor
Note over E: Outer think block starts
E->>R: ThinkRequest (outer)
R->>R: Create T1
R->>R: Push T1 onto stack
T1->>S: session/new
T1->>S: prompt (outer)
Note over T1: LLM decides to call do(0)
S-->>R: tool_call: do(0)
R-->>T1: DoInvocation(0)
T1->>E: ThinkResponse::Do(0)
Note over E: Evaluate do block,<br/>hits inner think
E->>R: ThinkRequest (inner)
R->>R: Create T2
R->>R: Push T2 onto stack
T2->>S: session/new
T2->>S: prompt (inner)
rect rgb(200, 230, 200)
Note over T2,S: Inner think conversation
S-->>R: notification (chunk)
R-->>T2: route to top of stack
S-->>R: PromptResponse
R-->>T2: complete
end
T2->>R: Pop T2
T2->>E: ThinkResponse::Complete (inner result)
Note over E: Inner think done,<br/>continue do block
E->>T1: do(0) result
rect rgb(200, 200, 230)
Note over T1,S: Outer think continues
S-->>R: notification (chunk)
R-->>T1: route to top of stack
S-->>R: PromptResponse
R-->>T1: complete
end
T1->>R: Pop T1
T1->>E: ThinkResponse::Complete (outer result)
Why This Doesn't Deadlock
The key insight is that different channels are used at each level:
| Component | Waits On | Sends To |
|---|---|---|
| Evaluator (outer think) | rx1 (std::sync::mpsc) | Agent via tx (tokio::sync::mpsc) |
| Think Handler 1 | think_rx1 (tokio::sync::mpsc) | Evaluator via response_tx1 |
| Evaluator (inner think) | rx2 (std::sync::mpsc) | Agent via tx (tokio::sync::mpsc) |
| Think Handler 2 | think_rx2 (tokio::sync::mpsc) | Evaluator via response_tx2 |
Each think block creates a fresh response_tx/response_rx pair. The evaluator blocks on its receiver, but the async runtime continues processing. When the inner think completes, it sends on response_tx2, unblocking the evaluator, which then sends the result back to the outer think handler.
The Channel Dance
Let's trace the channels at maximum nesting:
sequenceDiagram
participant E as Evaluator Thread
participant A as Agent
participant T1 as Think Handler 1
participant T2 as Think Handler 2
participant S as Successor
Note over E: eval_think_block (outer)
E->>A: ThinkRequest via tx
Note over E: blocked on rx1
A->>T1: Create handler
Note over A: Push T1 onto stack
T1->>S: prompt (outer)
S-->>T1: do(0) tool call
T1->>E: ThinkResponse::Do via rx1
Note over E: Unblocked
Note over E: eval do(0) block
Note over T1: Waiting for do result
Note over E: eval_think_block (inner)
E->>A: ThinkRequest via tx
Note over E: blocked on rx2
A->>T2: Create handler
Note over A: Push T2 onto stack
T2->>S: prompt (inner)
S-->>T2: response complete
Note over A: Pop T2
T2->>E: ThinkResponse::Complete via rx2
Note over E: Unblocked with inner result
E->>T1: do(0) result
Note over T1: Continue conversation
S-->>T1: response complete
Note over A: Pop T1
T1->>E: ThinkResponse::Complete via rx1
Note over E: Unblocked with outer result
Call Stack at Deepest Point
When the inner think is waiting for its LLM response, here's the state of each stack. Stacks grow upward—the top of each stack is the most recently pushed frame:
graph BT
subgraph EvalThread ["Evaluator Thread Call Stack"]
direction BT
E4["eval_think_block (outer)<br/>⏸ was blocked on rx1"]
E3["handle ThinkResponse::Do"]
E2["eval_block (do block body)"]
E1["eval_think_block (inner)<br/>⏸ blocked on rx2 ← top"]
E4 --> E3 --> E2 --> E1
end
subgraph RedirectStack ["Redirect Stack"]
direction BT
R1["T1 (outer)"]
R2["T2 (inner) ← top"]
R1 --> R2
end
subgraph AsyncTasks ["Async Tasks"]
direction BT
A3["redirect_actor<br/>routes to stack top"]
A2["think_handler T1<br/>⏸ waiting for do result"]
A1["think_handler T2<br/>⏸ waiting on think_rx2"]
end
The evaluator's call stack and the redirect stack grow in parallel. When the inner think completes, T2 pops off the redirect stack, and the evaluator unwinds back to the outer think's ThinkResponse::Do handler.
Arbitrary Depth
This pattern supports arbitrary nesting depth. Each level:
- Creates its own response channel pair
- Pushes a new think handler onto the redirect stack
- The innermost handler receives all notifications
- On completion, pops and returns control to the next level
The only limits are:
- Stack space in the evaluator thread (for deeply nested Rust calls)
- Memory for the channel buffers and think handlers
Implementation Notes
The redirect actor is simple—it just routes to the top of the stack:
#![allow(unused)] fn main() { async fn redirect_actor(mut rx: UnboundedReceiver<RedirectMessage>) { let mut stack: Vec<Sender<PerSessionMessage>> = vec![]; while let Some(message) = rx.recv().await { match message { RedirectMessage::IncomingMessage(msg) => { if let Some(sender) = stack.last() { sender.send(msg).await?; } } RedirectMessage::PushThinker(sender) => { stack.push(sender); } RedirectMessage::PopThinker => { stack.pop(); } } } } }
The complexity is in understanding the overall flow, not in any single component.