fabro/crates/coding-agent-loop
Bryan Helmkamp c9f28631b6 Consolidate test mocks and profiles in coding-agent-loop
Eliminate ~300 lines of duplicated test infrastructure:

- Add MockExecutionEnvironment::linux() constructor, replacing 4
  identical linux_env() helpers across profile test modules
- Extend MockExecutionEnvironment with written_files, captured_timeout,
  and apply_read_offset_limit fields to replace 4 specialized mocks
  (ReadFileEnv, WriteFileEnv, EditFileEnv, ShellCapturingEnv) in tools.rs
- Add MutableMockExecutionEnvironment for apply_patch tests that need
  writes visible to subsequent reads, replacing MockFileEnv in openai.rs
- Merge ParallelTestProfile into TestProfile with configurable
  parallel_tool_calls and context_window fields
- Replace ProviderTestProfile with TestProfile in provider_profile.rs
  tests, updating TestProfile::build_system_prompt to include user
  instructions like real profiles
- Extract shared CapturingLlmProvider into test_support.rs, replacing
  two inline capturing provider structs in session.rs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:41:04 -04:00
..
src Consolidate test mocks and profiles in coding-agent-loop 2026-02-22 12:41:04 -04:00
Cargo.toml Simplify coding-agent-loop: deduplicate mocks, narrow traits, type events 2026-02-20 16:34:29 -04:00
README.md Add README.md for each crate with usage examples 2026-02-21 14:12:21 -04:00

coding-agent-loop

A programmable agentic loop for building coding agents. This crate provides the core session management, tool execution, and LLM interaction loop used to power interactive coding assistants.

Architecture

The crate is organized around a central Session that drives an agentic loop:

  1. User input is appended to a conversation History
  2. The session builds a Request with system prompt, history, and tools
  3. An LLM generates a response (text and/or tool calls) via unified-llm
  4. Tool calls are executed through a ToolRegistry against an ExecutionEnvironment
  5. Results are recorded and the loop continues until the LLM responds with text only (natural completion), a turn limit is reached, or the session is aborted
User Input
    |
    v
[Session::process_input]
    |
    v
+-------------------+
| Build Request     |  <-- system prompt + history + tools
+-------------------+
    |
    v
+-------------------+
| LLM Call          |  <-- via unified-llm Client
+-------------------+
    |
    v
+-------------------+     +-------------------+
| Tool Calls?  -----+-yes-| Execute Tools     |
+-------------------+     | (parallel or seq)  |
    | no                  +-------------------+
    v                         |
  [Done]                      +---> loop back to Build Request

Key Components

  • Session -- Manages the full agentic loop: LLM calls, tool execution, steering, follow-ups, abort handling, and event emission.
  • ProviderProfile (trait) -- Defines how to build system prompts, which tools to register, and what capabilities a provider supports. Ships with AnthropicProfile, OpenAiProfile, and GeminiProfile.
  • ExecutionEnvironment (trait) -- Abstracts filesystem, shell, grep, and glob operations. LocalExecutionEnvironment provides a real implementation; the trait enables sandboxing and testing.
  • ToolRegistry -- Maps tool names to definitions and async executor functions. Tools are registered per-profile.
  • History -- Ordered list of Turn variants (User, Assistant, ToolResults, System, Steering) that converts to LLM messages.
  • EventEmitter -- Broadcasts SessionEvents (tool calls, text, errors, warnings) over a tokio::sync::broadcast channel for UI or logging.
  • SubAgentManager -- Spawns child Sessions on background tasks for delegated work, with depth limits.
  • SessionConfig -- Tunable parameters: max turns, tool round limits, command timeouts, loop detection, output truncation limits, and user instructions.

Key Types and Traits

Session

The main entry point. Created with an LLM client, a provider profile, an execution environment, and a config.

ProviderProfile

pub trait ProviderProfile: Send + Sync {
    fn id(&self) -> String;
    fn model(&self) -> String;
    fn tool_registry(&self) -> &ToolRegistry;
    fn build_system_prompt(
        &self,
        env: &dyn ExecutionEnvironment,
        env_context: &EnvContext,
        project_docs: &[String],
        user_instructions: Option<&str>,
    ) -> String;
    fn capabilities(&self) -> ProfileCapabilities;
    fn knowledge_cutoff(&self) -> &str;
    // ... default methods for tools(), provider_options(), supports_*()
}

Built-in profiles:

  • AnthropicProfile -- 200K context, extended thinking beta headers, tools: read_file, write_file, edit_file, shell, grep, glob
  • OpenAiProfile -- 128K context, reasoning effort support, tools: read_file, write_file, shell, grep, glob, apply_patch (v4a format)
  • GeminiProfile -- 1M context, safety settings, tools: all Anthropic tools plus read_many_files, list_dir, web_search, web_fetch

ExecutionEnvironment

pub trait ExecutionEnvironment: Send + Sync {
    async fn read_file(&self, path: &str, offset: Option<usize>, limit: Option<usize>) -> Result<String, String>;
    async fn write_file(&self, path: &str, content: &str) -> Result<(), String>;
    async fn exec_command(&self, command: &str, timeout_ms: u64, ...) -> Result<ExecResult, String>;
    async fn grep(&self, pattern: &str, path: &str, options: &GrepOptions) -> Result<Vec<String>, String>;
    async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>, String>;
    // ... plus delete_file, file_exists, list_directory, initialize, cleanup, platform info
}

LocalExecutionEnvironment is the real implementation with env-var filtering (strips secrets), process group management, and ripgrep/grep fallback.

SessionConfig

pub struct SessionConfig {
    pub max_turns: usize,                    // 0 = unlimited
    pub max_tool_rounds_per_input: usize,    // default: 200
    pub default_command_timeout_ms: u64,     // default: 10s
    pub max_command_timeout_ms: u64,         // default: 600s
    pub enable_loop_detection: bool,         // default: true
    pub loop_detection_window: usize,        // default: 10
    pub max_subagent_depth: usize,           // default: 1
    pub user_instructions: Option<String>,
    pub reasoning_effort: Option<String>,
    // ... plus tool_output_limits, tool_line_limits, git_root
}

Usage

use coding_agent_loop::{
    AnthropicProfile, LocalExecutionEnvironment, Session, SessionConfig,
};
use std::path::PathBuf;
use std::sync::Arc;
use unified_llm::client::Client;

// 1. Create an LLM client (via unified-llm)
let client: Client = /* configure unified-llm client */;

// 2. Choose a provider profile
let profile = Arc::new(AnthropicProfile::new("claude-sonnet-4-20250514"));

// 3. Create an execution environment
let env = Arc::new(LocalExecutionEnvironment::new(
    PathBuf::from("/path/to/project"),
));

// 4. Configure the session
let config = SessionConfig {
    max_tool_rounds_per_input: 50,
    enable_loop_detection: true,
    user_instructions: Some("Always write tests first".into()),
    ..SessionConfig::default()
};

// 5. Create and initialize the session
let mut session = Session::new(client, profile, env, config);
session.initialize().await;

// 6. Subscribe to events (for UI rendering)
let mut rx = session.subscribe();
tokio::spawn(async move {
    while let Ok(event) = rx.recv().await {
        // Handle SessionEvent: tool calls, text, errors, etc.
    }
});

// 7. Process user input
session.process_input("Fix the failing test in src/lib.rs").await?;

Steering and Follow-ups

Inject guidance mid-conversation or queue follow-up messages:

// Inject a steering message before the next LLM call
session.steer("Focus on the root cause, not symptoms".into());

// Queue a follow-up that runs after the current input completes
session.follow_up("Now run the test suite to verify".into());

Abort

Cancel a running session from another thread:

let abort_flag = session.abort_flag_handle();
// From another task:
abort_flag.store(true, std::sync::atomic::Ordering::SeqCst);

Custom Tools

Register additional tools via the profile's ToolRegistry:

use coding_agent_loop::tool_registry::{RegisteredTool, ToolExecutor};
use unified_llm::types::ToolDefinition;
use std::sync::Arc;

let custom_tool = RegisteredTool {
    definition: ToolDefinition {
        name: "my_tool".into(),
        description: "Does something useful".into(),
        parameters: serde_json::json!({
            "type": "object",
            "properties": {
                "input": {"type": "string"}
            },
            "required": ["input"]
        }),
    },
    executor: Arc::new(|args, env| {
        Box::pin(async move {
            let input = args["input"].as_str().unwrap_or("");
            Ok(format!("Processed: {input}"))
        })
    }),
};

// Register on a mutable profile before creating the session
profile.tool_registry_mut().register(custom_tool);

Subagents

Spawn child sessions for delegated tasks:

use coding_agent_loop::subagent::SubAgentManager;

let mut profile = AnthropicProfile::new("claude-sonnet-4-20250514");
let manager = Arc::new(tokio::sync::Mutex::new(SubAgentManager::new(3)));
let factory = Arc::new(|| { /* create a new Session */ });

// Registers spawn_agent, send_input, wait, close_agent tools
profile.register_subagent_tools(manager, factory, 0);

Safety Features

  • Loop detection -- Detects repeating tool call patterns (period 1, 2, or 3) and injects a steering warning
  • Context window monitoring -- Emits ContextWindowWarning events when estimated usage exceeds 80%
  • Tool argument validation -- Validates arguments against JSON Schema before execution
  • Tool output truncation -- Per-tool character and line limits with head/tail or tail-only truncation modes
  • Environment variable filtering -- LocalExecutionEnvironment strips secrets (*_API_KEY, *_SECRET, *_TOKEN, *_PASSWORD, *_CREDENTIAL) from subprocess environments
  • Command timeouts -- Configurable per-command with process group cleanup (SIGTERM then SIGKILL)
  • Project doc discovery -- Automatically discovers AGENTS.md, CLAUDE.md, GEMINI.md, or .codex/instructions.md based on provider, with a 32KB budget