Build a Coding Agent Harness in Rust

Build a Rust agent loop with tool dispatch and execution policy.

Introduction

30 Second Summary

When you use a coding assistant like Codex, there is an invisible system deciding when the AI should read a file, run a command, or simply answer your question. That orchestration layer is what separates a chatbot from a true coding agent.

In this project, you will build a terminal-based coding agent harness in Rust that implements the same core loop pattern used by OpenAI's Codex. You will wire up tool calling, execution policy, and multi-turn orchestration entirely from scratch.

What You'll Build

A Rust binary that orchestrates a local LLM through an agent loop of inference, tool dispatch, and policy enforcement.

By the end of this project, you'll have:

  • A model client that communicates with Ollama's chat API, sends tool definitions, and parses tool-call responses.
  • A tool registry with file and shell tools the model can invoke through structured tool calls.
  • An execution policy that blocks dangerous commands before they reach the shell.
  • A working demonstration of multi-turn tool use where the model reasons, calls tools, processes results, and produces final answers.
  • Secret Mission: Add context compaction that summarizes conversation history when it grows too long, mirroring Codex's compaction strategy.

Are there any prerequisites?

You need basic programming experience in any language. Rust syntax will be introduced as you go, so no prior Rust experience is required.

You will also need a machine with at least 16 GB of RAM to run the local model.

Build the Model Client

Your Rust project compiles, Ollama is serving qwen3 locally, and everything is wired up. The next goal is building the module that lets your Rust code actually talk to the model.

The model client handles all communication with Ollama's chat API. It needs to serialize messages into the right JSON format, POST them to the API, and parse responses that might contain either text answers or tool-call requests.

In this step, get ready to:
  • Define the message and request data structures for the Ollama chat API.
  • Implement an HTTP client that sends requests to Ollama.
  • Verify communication between your Rust program and qwen3.
Define the message types

Ollama's /api/chat endpoint expects a specific JSON structure. You need Rust structs that map to this format so you can serialize requests and deserialize responses cleanly.

  • Create a new file called src/model.rs in your project. Right-click the src folder in Cursor's Explorer sidebar, then click New File.
  • Name the file model.rs.
  • Add the following struct definitions to src/model.rs:
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
    pub role: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub content: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool_calls: Option<Vec<ToolCall>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool_name: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
    pub function: FunctionCall,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCall {
    pub name: String,
    pub arguments: serde_json::Value,
}

#[derive(Debug, Serialize)]
struct ChatRequest {
    model: String,
    messages: Vec<Message>,
    stream: bool,
    tools: Vec<serde_json::Value>,
}

#[derive(Debug, Deserialize)]
pub struct ChatResponse {
    pub message: Message,
}

What does this code do?

Message represents a single chat message. The role field can be "user", "assistant", "system", or "tool". The optional fields (content, tool_calls, tool_name) use skip_serializing_if so they only appear in the JSON when they have a value.

ToolCall and FunctionCall capture when the model wants to invoke a tool. The model returns the tool name and a JSON object of arguments.

ChatRequest is what you send to Ollama. ChatResponse is what you get back. The response contains a single Message that holds either text content or tool calls.

Implement the OllamaClient

Now you need an HTTP client that assembles requests and sends them to Ollama's local server at http://localhost:11434.

  • Add the following code below the struct definitions in src/model.rs:
pub struct OllamaClient {
    client: reqwest::Client,
    model: String,
    base_url: String,
}

impl OllamaClient {
    pub fn new(model: &str) -> Self {
        Self {
            client: reqwest::Client::new(),
            model: model.to_string(),
            base_url: "http://localhost:11434".to_string(),
        }
    }

    pub async fn chat(
        &self,
        messages: &[Message],
        tools: &[serde_json::Value],
    ) -> Result<ChatResponse, Box<dyn std::error::Error>> {
        let request = ChatRequest {
            model: self.model.clone(),
            messages: messages.to_vec(),
            stream: false,
            tools: tools.to_vec(),
        };

        let response = self
            .client
            .post(format!("{}/api/chat", self.base_url))
            .json(&request)
            .send()
            .await?
            .error_for_status()?
            .json::<ChatResponse>()
            .await?;

        Ok(response)
    }
}

What does this code do?

OllamaClient wraps a reqwest::Client (for making HTTP requests) with the model name and the Ollama server URL.

The chat method builds a ChatRequest, POSTs it to /api/chat, and deserializes the JSON response into a ChatResponse. Setting stream: false tells Ollama to return the full response as one JSON object instead of streaming tokens.

  • Save src/model.rs.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/model.rs before moving on.

ⓧ I'd like to double check the full code

Here is the complete src/model.rs file so far:

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
    pub role: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub content: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool_calls: Option<Vec<ToolCall>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool_name: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
    pub function: FunctionCall,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCall {
    pub name: String,
    pub arguments: serde_json::Value,
}

#[derive(Debug, Serialize)]
struct ChatRequest {
    model: String,
    messages: Vec<Message>,
    stream: bool,
    tools: Vec<serde_json::Value>,
}

#[derive(Debug, Deserialize)]
pub struct ChatResponse {
    pub message: Message,
}

pub struct OllamaClient {
    client: reqwest::Client,
    model: String,
    base_url: String,
}

impl OllamaClient {
    pub fn new(model: &str) -> Self {
        Self {
            client: reqwest::Client::new(),
            model: model.to_string(),
            base_url: "http://localhost:11434".to_string(),
        }
    }

    pub async fn chat(
        &self,
        messages: &[Message],
        tools: &[serde_json::Value],
    ) -> Result<ChatResponse, Box<dyn std::error::Error>> {
        let request = ChatRequest {
            model: self.model.clone(),
            messages: messages.to_vec(),
            stream: false,
            tools: tools.to_vec(),
        };

        let response = self
            .client
            .post(format!("{}/api/chat", self.base_url))
            .json(&request)
            .send()
            .await?
            .error_for_status()?
            .json::<ChatResponse>()
            .await?;

        Ok(response)
    }
}
Wire up and test

Time to connect the model client to your main program and verify that your Rust code can actually reach qwen3 through Ollama.

  • Open src/main.rs in Cursor and replace its entire contents with this test code:
mod model;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Create a client pointed at the qwen3 model
    let client = model::OllamaClient::new("qwen3");

    // Build a simple test message
    let messages = vec![model::Message {
        role: "user".to_string(),
        content: Some("Say hello in one word".to_string()),
        tool_calls: None,
        tool_name: None,
    }];

    // Send the message and print the response
    let response = client.chat(&messages, &[]).await?;
    println!("{:?}", response.message.content);
    Ok(())
}

What does this code do?

mod model; tells Rust to include your new model.rs file as a module. The #[tokio::main] attribute turns main into an async function so you can use .await for the HTTP request.

This sends a single user message to qwen3 with no tool definitions (the empty &[]). The model responds with plain text content, which gets printed to your terminal.

  • Save src/main.rs.
  • Run the program by executing this command in your terminal:
cargo run

You should see something like Some("Hello") or Some("Hello!") printed in your terminal. The exact word may vary, but seeing Some("...") confirms your Rust code successfully communicated with qwen3 through Ollama.

Why does it say Some?

The content field is an Option<String> in Rust. When it has a value, Rust's debug formatter prints it as Some("..."). When a tool call comes back instead of text, this field would be None.

Not seeing a response?

  • Make sure Ollama is running. Open a separate terminal and run ollama list to confirm it responds.
  • If you see a connection refused error, Ollama may not have started. Run ollama serve in a terminal to start it manually.
  • If the first run takes a long time, that is normal. Ollama loads the model into memory on the first request.

Help me debug my cargo run connection to Ollama

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/model.rs and src/main.rs.

ⓧ I'd like to double check the full code

Here is the complete src/main.rs at this point:

mod model;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = model::OllamaClient::new("qwen3");
    let messages = vec![model::Message {
        role: "user".to_string(),
        content: Some("Say hello in one word".to_string()),
        tool_calls: None,
        tool_name: None,
    }];
    let response = client.chat(&messages, &[]).await?;
    println!("{:?}", response.message.content);
    Ok(())
}

Your model client is working. Next up, you will build the tool registry that gives the model the ability to read files, write files, and execute shell commands.

Build the Tool Registry

Your model client can talk to Ollama. It sends messages and receives responses. But a raw LLM response is just text.

For the model to take real action in the world, it needs tools. A tool registry is the component that defines what tools exist, describes them to the model in a format it understands, and executes them when called. In this step, you will build a registry with three tools: reading files, writing files, and running shell commands.

In this step, get ready to:
  • Define tool schemas in the Ollama tool-calling JSON format.
  • Implement tool execution handlers for file and shell operations.
  • Wire up the registry and verify it can read, write, and run commands.
Define tool schemas

The model needs to know what tools are available before it can call them. You describe each tool using a JSON schema that includes the tool's name, a description of what it does, and the parameters it accepts.

What is a tool schema?

Ollama's tool-calling format expects each tool to be a JSON object with type: "function" and a function object containing the tool's name, description, and parameters. The parameters follow JSON Schema format with required fields and properties definitions.

When the model receives these schemas alongside a user message, it can decide which tool to call and generate the correct arguments.

  • Create a new file called src/tools.rs in the agent-harness project by right-clicking the src folder in the Explorer sidebar and selecting New File.
  • Add the following code to define the ToolRegistry struct and its tool definitions:
use serde_json::{json, Value};
use std::fs;
use std::process::Command;

pub struct ToolRegistry;

impl ToolRegistry {
    pub fn new() -> Self {
        Self
    }

    pub fn get_tool_definitions(&self) -> Vec<Value> {
        vec![
            json!({
                "type": "function",
                "function": {
                    "name": "read_file",
                    "description": "Read the contents of a file at the given path",
                    "parameters": {
                        "type": "object",
                        "required": ["path"],
                        "properties": {
                            "path": {
                                "type": "string",
                                "description": "The file path to read"
                            }
                        }
                    }
                }
            }),
            json!({
                "type": "function",
                "function": {
                    "name": "write_file",
                    "description": "Write content to a file at the given path",
                    "parameters": {
                        "type": "object",
                        "required": ["path", "content"],
                        "properties": {
                            "path": {
                                "type": "string",
                                "description": "The file path to write to"
                            },
                            "content": {
                                "type": "string",
                                "description": "The content to write to the file"
                            }
                        }
                    }
                }
            }),
            json!({
                "type": "function",
                "function": {
                    "name": "run_command",
                    "description": "Run a shell command and return its output",
                    "parameters": {
                        "type": "object",
                        "required": ["command"],
                        "properties": {
                            "command": {
                                "type": "string",
                                "description": "The shell command to execute"
                            }
                        }
                    }
                }
            }),
        ]
    }
}

What does this code do?

  • ToolRegistry is a unit struct that holds no data. It serves as a namespace for tool-related methods.
  • get_tool_definitions() returns a vector of JSON values describing each tool. These get sent to Ollama alongside your messages so the model knows what it can call.
  • Each tool definition follows the exact format Ollama expects: type: "function" wrapping a function object with name, description, and a JSON Schema for its parameters.
  • Save src/tools.rs.

Three tools cover most coding agent needs

Read, write, and execute. These three capabilities let the model inspect existing code, create or modify files, and run commands to test its work. This is the same minimal tool set that production coding agents start with.

Implement tool execution

Defining schemas tells the model what tools exist. Now you need the code that actually runs when the model calls a tool.

  • In src/tools.rs, find the closing } of the impl ToolRegistry block (the last } in the file).
  • Add the following methods inside the impl block, just above that closing brace:
    pub fn execute(&self, name: &str, arguments: &Value) -> String {
        match name {
            "read_file" => self.read_file(arguments),
            "write_file" => self.write_file(arguments),
            "run_command" => self.run_command(arguments),
            _ => format!("Unknown tool: {}", name),
        }
    }

    fn read_file(&self, args: &Value) -> String {
        let path = args["path"].as_str().unwrap_or("");
        match fs::read_to_string(path) {
            Ok(content) => content,
            Err(e) => format!("Error reading file: {}", e),
        }
    }

    fn write_file(&self, args: &Value) -> String {
        let path = args["path"].as_str().unwrap_or("");
        let content = args["content"].as_str().unwrap_or("");
        match fs::write(path, content) {
            Ok(_) => format!("Successfully wrote to {}", path),
            Err(e) => format!("Error writing file: {}", e),
        }
    }

    fn run_command(&self, args: &Value) -> String {
        let command = args["command"].as_str().unwrap_or("");
        match Command::new("cmd").args(["/C", command]).output() {
            Ok(output) => {
                let stdout = String::from_utf8_lossy(&output.stdout);
                let stderr = String::from_utf8_lossy(&output.stderr);
                if output.status.success() {
                    stdout.to_string()
                } else {
                    format!(
                        "Command failed (exit {}):\n{}{}",
                        output.status.code().unwrap_or(-1),
                        stdout,
                        stderr
                    )
                }
            }
            Err(e) => format!("Error running command: {}", e),
        }
    }

What does this code do?

  • execute() is the dispatch method. It takes a tool name and JSON arguments, then routes to the correct handler using Rust's match expression.
  • read_file() extracts the path argument and uses std::fs::read_to_string to read the file contents. If the file does not exist, it returns a descriptive error.
  • write_file() extracts path and content arguments, then writes the content to disk with std::fs::write.
  • run_command() passes the command string to cmd /C on Windows, captures both stdout and stderr, and returns the appropriate output based on exit status.

Why cmd /C for shell execution?

On Windows, cmd /C runs a command string through the system shell and then exits. This lets you execute built-in commands like dir and echo that are not standalone executables. Without the shell, you could only run .exe files directly.

  • Save src/tools.rs.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/tools.rs before moving on.

ⓧ I'd like to double check the full code

Here is the complete src/tools.rs file:

use serde_json::{json, Value};
use std::fs;
use std::process::Command;

pub struct ToolRegistry;

impl ToolRegistry {
    pub fn new() -> Self {
        Self
    }

    pub fn get_tool_definitions(&self) -> Vec<Value> {
        vec![
            json!({
                "type": "function",
                "function": {
                    "name": "read_file",
                    "description": "Read the contents of a file at the given path",
                    "parameters": {
                        "type": "object",
                        "required": ["path"],
                        "properties": {
                            "path": {
                                "type": "string",
                                "description": "The file path to read"
                            }
                        }
                    }
                }
            }),
            json!({
                "type": "function",
                "function": {
                    "name": "write_file",
                    "description": "Write content to a file at the given path",
                    "parameters": {
                        "type": "object",
                        "required": ["path", "content"],
                        "properties": {
                            "path": {
                                "type": "string",
                                "description": "The file path to write to"
                            },
                            "content": {
                                "type": "string",
                                "description": "The content to write to the file"
                            }
                        }
                    }
                }
            }),
            json!({
                "type": "function",
                "function": {
                    "name": "run_command",
                    "description": "Run a shell command and return its output",
                    "parameters": {
                        "type": "object",
                        "required": ["command"],
                        "properties": {
                            "command": {
                                "type": "string",
                                "description": "The shell command to execute"
                            }
                        }
                    }
                }
            }),
        ]
    }

    pub fn execute(&self, name: &str, arguments: &Value) -> String {
        match name {
            "read_file" => self.read_file(arguments),
            "write_file" => self.write_file(arguments),
            "run_command" => self.run_command(arguments),
            _ => format!("Unknown tool: {}", name),
        }
    }

    fn read_file(&self, args: &Value) -> String {
        let path = args["path"].as_str().unwrap_or("");
        match fs::read_to_string(path) {
            Ok(content) => content,
            Err(e) => format!("Error reading file: {}", e),
        }
    }

    fn write_file(&self, args: &Value) -> String {
        let path = args["path"].as_str().unwrap_or("");
        let content = args["content"].as_str().unwrap_or("");
        match fs::write(path, content) {
            Ok(_) => format!("Successfully wrote to {}", path),
            Err(e) => format!("Error writing file: {}", e),
        }
    }

    fn run_command(&self, args: &Value) -> String {
        let command = args["command"].as_str().unwrap_or("");
        match Command::new("cmd").args(["/C", command]).output() {
            Ok(output) => {
                let stdout = String::from_utf8_lossy(&output.stdout);
                let stderr = String::from_utf8_lossy(&output.stderr);
                if output.status.success() {
                    stdout.to_string()
                } else {
                    format!(
                        "Command failed (exit {}):\n{}{}",
                        output.status.code().unwrap_or(-1),
                        stdout,
                        stderr
                    )
                }
            }
            Err(e) => format!("Error running command: {}", e),
        }
    }
}
Test tool execution

With the registry built, you need to wire it into main.rs and confirm it can actually read a file from disk.

  • Open src/main.rs and replace the entire file contents with the following code:
mod model;
mod tools;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Create a tool registry and test reading a file
    let tool_registry = tools::ToolRegistry::new();
    let result = tool_registry.execute("read_file", &serde_json::json!({"path": "Cargo.toml"}));
    println!("{}", result);
    Ok(())
}

What does this code do?

  • mod tools; registers your new tools module with the Rust compiler so it gets compiled alongside the rest of your project.
  • The main function creates a ToolRegistry, then calls execute("read_file", ...) with Cargo.toml as the target path. This exercises the full dispatch chain: name matching, argument extraction, and file system access.
  • Save src/main.rs.
  • Run the project by executing this command in your terminal:
cargo run
  • You should see the contents of your Cargo.toml file printed to the terminal, including the package name agent-harness and all four dependencies.

Not seeing your Cargo.toml contents?

  • If you see a compilation error about mod tools, make sure the file is named exactly tools.rs inside the src/ folder. Rust resolves module names from file names.
  • If you see Error reading file in the output, make sure you are running cargo run from inside the agent-harness directory where Cargo.toml lives.
  • If you get an unresolved import error for serde_json, run cargo build first to download the dependency.

Still stuck? Help me debug why my tool registry isn't reading files correctly.

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/tools.rs and src/main.rs.

ⓧ I'd like to double check the full code

Here is the complete src/main.rs file:

mod model;
mod tools;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let tool_registry = tools::ToolRegistry::new();
    let result = tool_registry.execute("read_file", &serde_json::json!({"path": "Cargo.toml"}));
    println!("{}", result);
    Ok(())
}

Your tool registry is ready. It can read files, write files, and run shell commands on demand. Next up, you will build the agent loop that connects user input to the model and the model to these tools, creating the full inference-action cycle.

Build the Agent Loop

You now have a model client that talks to Ollama and a tool registry that can execute file and shell operations. The missing piece is the orchestration layer that ties them together.

In this step, you'll build the agent loop. This is the heart of the harness: accept user input, send it to the model with tool definitions, detect tool calls in the response, execute them, feed results back, and repeat until the model produces a final answer.

In this step, get ready to:
  • Build an interactive loop that reads user input and constructs message history.
  • Implement the inner agent loop that detects and executes tool calls from the model.
  • Test multi-turn tool use where the model reasons, calls tools, and produces final answers.
Implement the interactive loop

The agent loop follows the exact pattern described in OpenAI's "Unrolling the Codex agent loop" blog post: prompt the model, check if it wants to call tools, execute those tools, feed results back, and repeat.

You'll replace the test code in src/main.rs with the full interactive agent. The outer loop reads user input from stdin. For each input, it constructs a messages vec with a system message (telling the model it has tools) and the user's request.

  • In Cursor, open src/main.rs.
  • Select all existing code and replace it with the following:
mod model;
mod tools;

use std::io::{self, Write};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    println!("=== Agent Harness v0.1 ===");
    println!("A Rust coding agent powered by Ollama + qwen3");
    println!("Type your request (or 'quit' to exit):\n");

    let client = model::OllamaClient::new("qwen3");
    let tool_registry = tools::ToolRegistry::new();

    loop {
        print!("> ");
        io::stdout().flush()?;

        let mut input = String::new();
        io::stdin().read_line(&mut input)?;
        let input = input.trim();

        if input.is_empty() { continue; }
        if input == "quit" { println!("Goodbye."); break; }

        let mut messages = vec![
            model::Message {
                role: "system".to_string(),
                content: Some("You are a coding assistant. Use the provided tools to help the user with file operations and shell commands. You can call multiple tools in sequence to complete a task.".to_string()),
                tool_calls: None,
                tool_name: None,
            },
            model::Message {
                role: "user".to_string(),
                content: Some(input.to_string()),
                tool_calls: None,
                tool_name: None,
            },
        ];

        // Agent loop: call model, execute tools, repeat until done
        loop {
            let response = client.chat(&messages, &tool_registry.get_tool_definitions()).await;
            let response = match response {
                Ok(r) => r,
                Err(e) => { eprintln!("[ERROR] Model request failed: {}", e); break; }
            };

            if let Some(tool_calls) = &response.message.tool_calls {
                // Model wants to call tools - push assistant message to history
                messages.push(response.message.clone());
                for call in tool_calls {
                    let tool_name = &call.function.name;
                    let arguments = &call.function.arguments;
                    println!("[TOOL] Executing: {}({})", tool_name, arguments);
                    let result = tool_registry.execute(tool_name, arguments);
                    println!("[RESULT] {}", &result);
                    messages.push(model::Message {
                        role: "tool".to_string(),
                        content: Some(result),
                        tool_calls: None,
                        tool_name: Some(tool_name.clone()),
                    });
                }
                // Loop back to let model process tool results
            } else {
                // No tool calls - model produced a final answer
                if let Some(content) = &response.message.content {
                    println!("\n{}\n", content);
                }
                break;
            }
        }
    }

    Ok(())
}

What does the outer loop do?

  • use std::io::{self, Write} brings in I/O traits so you can flush the prompt to the terminal before waiting for input.
  • The outer loop prints a > prompt, reads a line from stdin, trims whitespace, and skips empty input or exits on quit.
  • For each valid input, it constructs a fresh messages vec. The system message tells the model it is a coding assistant with tool access. The user message contains what the learner typed.
Understand the inner agent loop

The inner loop is where the agent pattern lives. It calls the model, inspects the response, and decides what to do next.

What does the inner loop do?

  • client.chat(&messages, &tool_registry.get_tool_definitions()) sends the full conversation history plus tool schemas to the model.
  • If the response contains tool_calls, the model wants to use a tool. The code pushes the assistant message into history, executes each tool call via tool_registry.execute(), and pushes the result as a "tool" role message. Then it loops back to let the model process those results.
  • If there are no tool_calls, the model has produced a final answer. The code prints the content and breaks out of the inner loop, returning to the > prompt.

This is the ReAct pattern: reason (model infers), act (tool executes), observe (results fed back), repeat. It is the exact architecture described in the Codex agent loop.

Why rebuild messages from scratch each turn?

Each user prompt starts a fresh messages vec. This keeps things simple for now. The model gets full context within a single multi-tool exchange, but forgets between prompts.

The Secret Mission later adds persistent session memory and compaction to handle longer conversations.

Test multi-turn tool use
  • Save src/main.rs.
  • Run the agent by executing this command in your terminal:
cargo run

You should see the banner followed by a > prompt waiting for your input.

  • Type this prompt and press Enter:
What files are in the current directory?

Watch the output. You should see [TOOL] Executing: run_command(...) followed by [RESULT] showing the directory listing, and then the model's natural language summary.

  • Try a second prompt that exercises the read_file tool:
Read the Cargo.toml file and tell me what dependencies this project uses

The model should call read_file with Cargo.toml as the path, receive the file contents, then produce a summary of your dependencies.

  • Type quit to exit the agent when you are done testing.

What should I see?

The key thing to look for is the multi-turn pattern. The model does not just answer your question directly. It first decides which tool to call, the harness executes it, and then the model uses the result to compose a final answer.

The first model response may take 10-30 seconds depending on your hardware, since qwen3 runs locally on CPU/GPU. Subsequent responses in the same turn are faster because the model is already loaded.

Model request failed or connection refused?

  • Make sure Ollama is still running. Open a separate terminal and run ollama list to check. If it is not running, start it with ollama serve.
  • If you see a deserialization error, the model may not be returning tool calls in the expected format. Make sure you pulled qwen3 (not a different model variant).
  • If the model responds with text instead of calling tools, try rephrasing your prompt to be more explicit (e.g. "Use the run_command tool to list files in this directory").

Still stuck? Help me debug why my agent loop isn't getting tool call responses from the model.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/main.rs and that cargo run launches the interactive prompt successfully.

ⓧ I'd like to double check the full code

Here is the complete src/main.rs at this point:

mod model;
mod tools;

use std::io::{self, Write};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    println!("=== Agent Harness v0.1 ===");
    println!("A Rust coding agent powered by Ollama + qwen3");
    println!("Type your request (or 'quit' to exit):\n");

    let client = model::OllamaClient::new("qwen3");
    let tool_registry = tools::ToolRegistry::new();

    loop {
        print!("> ");
        io::stdout().flush()?;

        let mut input = String::new();
        io::stdin().read_line(&mut input)?;
        let input = input.trim();

        if input.is_empty() { continue; }
        if input == "quit" { println!("Goodbye."); break; }

        let mut messages = vec![
            model::Message {
                role: "system".to_string(),
                content: Some("You are a coding assistant. Use the provided tools to help the user with file operations and shell commands. You can call multiple tools in sequence to complete a task.".to_string()),
                tool_calls: None,
                tool_name: None,
            },
            model::Message {
                role: "user".to_string(),
                content: Some(input.to_string()),
                tool_calls: None,
                tool_name: None,
            },
        ];

        // Agent loop: call model, execute tools, repeat until done
        loop {
            let response = client.chat(&messages, &tool_registry.get_tool_definitions()).await;
            let response = match response {
                Ok(r) => r,
                Err(e) => { eprintln!("[ERROR] Model request failed: {}", e); break; }
            };

            if let Some(tool_calls) = &response.message.tool_calls {
                // Model wants to call tools - push assistant message to history
                messages.push(response.message.clone());
                for call in tool_calls {
                    let tool_name = &call.function.name;
                    let arguments = &call.function.arguments;
                    println!("[TOOL] Executing: {}({})", tool_name, arguments);
                    let result = tool_registry.execute(tool_name, arguments);
                    println!("[RESULT] {}", &result);
                    messages.push(model::Message {
                        role: "tool".to_string(),
                        content: Some(result),
                        tool_calls: None,
                        tool_name: Some(tool_name.clone()),
                    });
                }
                // Loop back to let model process tool results
            } else {
                // No tool calls - model produced a final answer
                if let Some(content) = &response.message.content {
                    println!("\n{}\n", content);
                }
                break;
            }
        }
    }

    Ok(())
}

Your agent loop is working. The model can now reason about what tools to call, execute them through your registry, and synthesize results into coherent answers. Next up, you'll add an execution policy layer that blocks dangerous commands before they reach the shell.

Add Execution Policy

Your agent loop can now reason and call tools, but there is nothing stopping it from executing a destructive command if the model decides to.

In this step, you will add an execution policy layer that inspects every tool call before it runs. This mirrors the codex-execpolicy crate in the real Codex architecture, which evaluates commands against allow/deny rules before they reach the shell.

In this step, get ready to:
  • Define a policy module with blocked command patterns.
  • Implement policy checks that gate each tool type differently.
  • Integrate the policy into the agent loop so denied calls never execute.
Define the policy module

The execution policy is a standalone module that holds a list of dangerous command patterns. When the agent loop wants to run a tool, it asks the policy whether the call is allowed.

  • Create a new file called src/policy.rs inside your agent-harness project.
  • Add the following code to define the ExecutionPolicy struct and its blocked patterns:
use serde_json::Value;

pub struct ExecutionPolicy {
    blocked_commands: Vec<String>,
}

impl ExecutionPolicy {
    pub fn new() -> Self {
        Self {
            blocked_commands: vec![
                "rm -rf".to_string(),
                "del /f /s /q".to_string(),
                "format ".to_string(),
                "shutdown".to_string(),
                "reg delete".to_string(),
                "rmdir /s".to_string(),
            ],
        }
    }
}

What does this code do?

The ExecutionPolicy struct holds a Vec<String> of command fragments that should never be executed.

Each entry is a substring pattern. If any tool call's command contains one of these substrings, the policy will reject it. The patterns cover recursive deletion, disk formatting, system shutdown, and registry manipulation.

  • Save src/policy.rs.

Seeing a red underline on Value?

The use serde_json::Value import will show as unused until you add the allow method in the next substep. This is expected.

help me understand why Rust shows warnings for unused imports

Implement policy checks

Each tool type needs a different policy rule. Reading files is always safe. Writing files should be restricted to the working directory. Shell commands need to be checked against the blocked patterns.

  • In src/policy.rs, add the allow method inside the existing impl ExecutionPolicy block, below the closing brace of new():
    pub fn allow(&self, tool_name: &str, arguments: &Value) -> bool {
        match tool_name {
            "run_command" => {
                let command = arguments["command"].as_str().unwrap_or("");
                let command_lower = command.to_lowercase();
                for blocked in &self.blocked_commands {
                    if command_lower.contains(&blocked.to_lowercase()) {
                        return false;
                    }
                }
                true
            }
            "write_file" => {
                let path = arguments["path"].as_str().unwrap_or("");
                // Block writes outside current directory
                !path.contains("..") && !path.starts_with('/') && !path.contains(":\\")
            }
            "read_file" => true,
            _ => false,
        }
    }

What does the allow method do?

It takes a tool name and its arguments, then returns true if the call is safe or false if it should be blocked.

  • For run_command: converts the command to lowercase and checks whether it contains any blocked pattern.
  • For write_file: blocks paths that traverse upward (..), start with /, or contain a Windows drive letter like :\.
  • For read_file: always allows, because reading is a safe operation.
  • For any unknown tool: denies by default.
  • Save src/policy.rs.
  • The unused-import warning on Value should now disappear.

Seeing a syntax error?

Make sure the allow method is placed inside the impl ExecutionPolicy { ... } block, after the new() function's closing brace but before the impl block's final }.

help me fix a syntax error in my policy.rs file

Integrate the policy into the agent loop

The policy module exists, but the agent loop does not know about it yet. You need to register the module, create a policy instance, and gate every tool call through it.

  • In src/main.rs, find this line near the top:
mod tools;
  • Add mod policy; directly above it, so the top of the file reads:
mod model;
mod policy;
mod tools;
  • Next, find this line inside the main function:
    let tool_registry = tools::ToolRegistry::new();
  • Add the following line directly below it:
    let exec_policy = policy::ExecutionPolicy::new();
  • Now find the for call in tool_calls loop body. It currently looks like this:
                for call in tool_calls {
                    let tool_name = &call.function.name;
                    let arguments = &call.function.arguments;
                    println!("[TOOL] Executing: {}({})", tool_name, arguments);
                    let result = tool_registry.execute(tool_name, arguments);
                    println!("[RESULT] {}", &result);
                    messages.push(model::Message {
                        role: "tool".to_string(),
                        content: Some(result),
                        tool_calls: None,
                        tool_name: Some(tool_name.clone()),
                    });
                }
  • Replace that entire for loop with this version that checks the policy before executing:
                for call in tool_calls {
                    let tool_name = &call.function.name;
                    let arguments = &call.function.arguments;

                    // Check execution policy before running
                    if !exec_policy.allow(tool_name, arguments) {
                        println!("[POLICY] Blocked: {} with {}", tool_name, arguments);
                        messages.push(model::Message {
                            role: "tool".to_string(),
                            content: Some("Permission denied by execution policy.".to_string()),
                            tool_calls: None,
                            tool_name: Some(tool_name.clone()),
                        });
                        continue;
                    }

                    println!("[TOOL] Executing: {}({})", tool_name, arguments);
                    let result = tool_registry.execute(tool_name, arguments);
                    println!("[RESULT] {}", &result);

                    messages.push(model::Message {
                        role: "tool".to_string(),
                        content: Some(result),
                        tool_calls: None,
                        tool_name: Some(tool_name.clone()),
                    });
                }

What changed in the agent loop?

Before calling tool_registry.execute(), the loop now calls exec_policy.allow().

If the policy denies the call, the loop pushes a tool-role message saying "Permission denied by execution policy." back to the model. The model sees this as a tool result and can adapt (e.g., try a different approach or inform the user). The continue keyword skips the actual execution entirely.

  • Save src/main.rs.
  • Build the project to confirm everything compiles by running:
cargo build
  • You should see a successful build with no errors.
  • Now test the policy by running the agent:
cargo run
  • At the > prompt, type: run the command: shutdown /s and press Enter.
  • You should see [POLICY] Blocked: run_command with ... in the output, confirming the policy intercepted the dangerous command.
  • Now try a safe request like: What files are in the current directory? to confirm safe commands still work.
  • Type quit to exit.

Not seeing the POLICY log?

The model might phrase the command differently than expected. If it does not trigger a blocked pattern, try being more explicit: "execute this shell command exactly: shutdown /s" to force the model to call run_command with that exact string.

Also confirm that mod policy; appears in src/main.rs and that exec_policy.allow(tool_name, arguments) is called inside the for call in tool_calls loop.

help me debug why my execution policy is not blocking dangerous commands

Why this approach instead of sandboxing?

The real Codex architecture uses both: codex-execpolicy evaluates commands against prefix-based rules (allow, prompt, or forbidden), and a separate sandbox layer restricts filesystem access.

A pattern-matching policy is simpler to implement, easy to extend with new rules, and gives the model immediate feedback when a command is denied. Sandboxing adds defence-in-depth but requires OS-level isolation that is beyond the scope of this project.

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/policy.rs and src/main.rs.

ⓧ I'd like to double check the full code

Here is the complete src/policy.rs:

use serde_json::Value;

pub struct ExecutionPolicy {
    blocked_commands: Vec<String>,
}

impl ExecutionPolicy {
    pub fn new() -> Self {
        Self {
            blocked_commands: vec![
                "rm -rf".to_string(),
                "del /f /s /q".to_string(),
                "format ".to_string(),
                "shutdown".to_string(),
                "reg delete".to_string(),
                "rmdir /s".to_string(),
            ],
        }
    }

    pub fn allow(&self, tool_name: &str, arguments: &Value) -> bool {
        match tool_name {
            "run_command" => {
                let command = arguments["command"].as_str().unwrap_or("");
                let command_lower = command.to_lowercase();
                for blocked in &self.blocked_commands {
                    if command_lower.contains(&blocked.to_lowercase()) {
                        return false;
                    }
                }
                true
            }
            "write_file" => {
                let path = arguments["path"].as_str().unwrap_or("");
                // Block writes outside current directory
                !path.contains("..") && !path.starts_with('/') && !path.contains(":\\")
            }
            "read_file" => true,
            _ => false,
        }
    }
}

And the complete src/main.rs:

mod model;
mod policy;
mod tools;

use std::io::{self, Write};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    println!("=== Agent Harness v0.1 ===");
    println!("A Rust coding agent powered by Ollama + qwen3");
    println!("Type your request (or 'quit' to exit):\n");

    let client = model::OllamaClient::new("qwen3");
    let tool_registry = tools::ToolRegistry::new();
    let exec_policy = policy::ExecutionPolicy::new();

    loop {
        print!("> ");
        io::stdout().flush()?;

        let mut input = String::new();
        io::stdin().read_line(&mut input)?;
        let input = input.trim();

        if input.is_empty() { continue; }
        if input == "quit" { println!("Goodbye."); break; }

        let mut messages = vec![
            model::Message {
                role: "system".to_string(),
                content: Some("You are a coding assistant. Use the provided tools to help the user with file operations and shell commands. You can call multiple tools in sequence to complete a task.".to_string()),
                tool_calls: None,
                tool_name: None,
            },
            model::Message {
                role: "user".to_string(),
                content: Some(input.to_string()),
                tool_calls: None,
                tool_name: None,
            },
        ];

        loop {
            let response = client.chat(&messages, &tool_registry.get_tool_definitions()).await;
            let response = match response {
                Ok(r) => r,
                Err(e) => { eprintln!("[ERROR] Model request failed: {}", e); break; }
            };

            if let Some(tool_calls) = &response.message.tool_calls {
                messages.push(response.message.clone());
                for call in tool_calls {
                    let tool_name = &call.function.name;
                    let arguments = &call.function.arguments;

                    if !exec_policy.allow(tool_name, arguments) {
                        println!("[POLICY] Blocked: {} with {}", tool_name, arguments);
                        messages.push(model::Message {
                            role: "tool".to_string(),
                            content: Some("Permission denied by execution policy.".to_string()),
                            tool_calls: None,
                            tool_name: Some(tool_name.clone()),
                        });
                        continue;
                    }

                    println!("[TOOL] Executing: {}({})", tool_name, arguments);
                    let result = tool_registry.execute(tool_name, arguments);
                    println!("[RESULT] {}", &result);

                    messages.push(model::Message {
                        role: "tool".to_string(),
                        content: Some(result),
                        tool_calls: None,
                        tool_name: Some(tool_name.clone()),
                    });
                }
            } else {
                if let Some(content) = &response.message.content {
                    println!("\n{}\n", content);
                }
                break;
            }
        }
    }

    Ok(())
}

Secret mission

Add Context Compaction

When conversations grow long, the context window fills up and the model loses coherence. Implement a compaction strategy that summarizes old messages to free up context, mirroring the approach described in OpenAI's Codex architecture.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Ollama uses no GPU or CPU when idle. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Rust toolchain (installed via rustup, no ongoing cost).
  • Ollama with the qwen3 model (~4.7GB disk space, no ongoing cost).
  • The agent-harness project folder on your Desktop (local files only).

Keep everything running

No action needed. Choose this if you plan to keep experimenting with your agent harness or building on the project.

  • Ollama runs as a background service but uses no GPU or CPU when idle. The model files remain on disk.
  • The Rust toolchain stays installed and ready for future projects.
  • Your agent-harness folder stays on your Desktop.

Pause - I'll come back to this later

Stop the Ollama background service to free up system resources. Your model files and project folder remain on disk for when you return.

  • Open Task Manager (press Ctrl+Shift+Esc).
  • Find the Ollama process and click End task.
  • When you want to restart, open a terminal and run:
ollama serve
  • Your project folder and Rust toolchain require no pausing.

Delete - I don't want to use this again

Remove all project resources and tools from your system.

  • Delete the project folder by running this in PowerShell:
Remove-Item -Recurse -Force "$HOME\Desktop\agent-harness"
  • Uninstall the Rust toolchain by running:
rustup self uninstall
  • Uninstall Ollama through Windows Settings:
  • Open Settings.
  • Select Apps, then Add or remove programs.
  • Find Ollama in the list and click Uninstall.
  • When prompted, leave the Remove models checkbox selected to delete the ~4.7GB qwen3 model files.

Nice Work!

Nice Work!

You just built the core infrastructure that turns a language model into an autonomous coding agent. This is the exact architectural pattern described in OpenAI's "Unrolling the Codex agent loop" blog post, implemented from scratch in Rust.

You've learned how to:

  • Build a Rust-based agent harness that orchestrates an LLM through a loop of inference, tool dispatch, and result feedback until the model produces a final answer.
  • Implement a tool registry with file and shell tools that the model invokes through structured tool calls, paired with an execution policy that blocks dangerous operations before they reach the shell.
  • Demonstrate multi-turn agentic behavior where the model reasons about a task, calls tools to gather information, processes results, and produces coherent answers.
  • Secret Mission: Implemented context compaction to summarize conversation history when it grows too long, managing context length for long-running agent sessions.

Ready to quiz yourself?