agent-harness-rs 0.2.13

Agent loop harness with local and sandbox tool runtimes, context management, and MCP support
Documentation

agent-harness-rs

Crates.io docs.rs License: MIT

Agent loop harness for building LLM-powered coding agents. Provides a complete runtime with tool execution, context management, MCP support, and e2b sandbox integration.

Features

  • Agent loop — OpenAI-compatible Chat Completions, OpenAI Responses, and Anthropic streaming model clients with retry, reconnect, silent-stop detection, and compaction
  • Local toolsbash, read, write, edit, glob, grep, web_fetch with approval gate (feature = "local-tools", default)
  • Web search routing — Explicit Off, Auto, Native, or Managed policy across provider-hosted search and pluggable managed providers
  • Sandbox tools — Generic SandboxExecutor trait for any remote sandbox
  • E2b integrationE2bToolRuntime via Connect Protocol to envd (feature = "e2b")
  • Context persistence — JSONL-based context store with incremental append and compaction rewrite
  • MCP support — HTTP and stdio MCP server integration via CompositeToolRuntime
  • Model limits catalog — async models.dev fetch with disk cache, backoff retry, and offline fallback for context-window / output-token resolution

Quick start

[dependencies]
agent-harness-rs = "0.2"

# For e2b sandbox support:
agent-harness-rs = { version = "0.2", features = ["e2b"] }
use harness::{
    AgentLoopHarness, NativeTurnInput, OpenAiCompatibleConfig, OpenAiCompatibleModelClient,
    LocalToolRuntime, LocalToolConfig, ModelCatalog, ModelRequestConfig, ReasoningConfig,
    WireProtocol, YoloApproval,
};
use std::sync::Arc;
use std::path::PathBuf;

// Local tool runtime (runs bash/read/write on your machine)
let tools = LocalToolRuntime::new(LocalToolConfig {
    cwd: Some(PathBuf::from("/path/to/project")),
    approval: Arc::new(YoloApproval),
    emit: Arc::new(|_| {}),
});

let resolved_model = ModelCatalog::initialize().await?.resolve(
    ModelRequestConfig {
        model: "gpt-5.5".into(),
        max_output_tokens: 4096,
        temperature: None,
        reasoning: ReasoningConfig::default(),
    },
    WireProtocol::OpenAiCompatible,
).await?;

let model = OpenAiCompatibleModelClient::new(OpenAiCompatibleConfig {
    // Full API prefix INCLUDING the version segment. The client appends only
    // `/chat/completions`. For other OpenAI-compatible providers use their own
    // prefix, e.g. GLM: "https://open.bigmodel.cn/api/paas/v4".
    base_url: "https://api.openai.com/v1".into(),
    api_key: std::env::var("OPENAI_API_KEY").unwrap(),
    model: resolved_model,
});

let harness = AgentLoopHarness::new(model, tools);

let mut rx = harness.run_turn(NativeTurnInput {
    prompt_text: "List the Rust files in this project".into(),
    system_prompt: None,
    attachments: vec![],
    cancel_token: None,
    prior_messages: vec![],
    context_path: Some(PathBuf::from("/tmp/my-session.jsonl")),
}).await?;

while let Some(event) = rx.recv().await {
    println!("{event:?}");
}

OpenAI Responses API

Use OpenAiResponsesModelClient for OpenAI's Responses API and GPT-5 / reasoning-model workflows. The client appends /responses to base_url unless the route is already present.

use harness::{
    AgentLoopHarness, ModelCatalog, ModelRequestConfig, NativeTurnInput,
    OpenAiResponsesConfig, OpenAiResponsesModelClient, ReasoningConfig,
    ReasoningMode, WireProtocol,
};
use std::path::PathBuf;

let resolved_model = ModelCatalog::initialize().await?.resolve(
    ModelRequestConfig {
        model: "gpt-5.5".into(),
        max_output_tokens: 4096,
        temperature: None,
        reasoning: ReasoningConfig {
            mode: ReasoningMode::Enabled,
            effort: Some("medium".into()),
            budget_tokens: None,
        },
    },
    WireProtocol::OpenAiResponses,
).await?;

let model = OpenAiResponsesModelClient::new(OpenAiResponsesConfig {
    base_url: OpenAiResponsesConfig::DEFAULT_BASE_URL.into(),
    api_key: std::env::var("OPENAI_API_KEY").unwrap(),
    model: resolved_model,
    reasoning_summary: Some("auto".into()),
});

let harness = AgentLoopHarness::new(model, tools);

let mut rx = harness.run_turn(NativeTurnInput {
    prompt_text: "Inspect this repository and summarize the main risks".into(),
    system_prompt: None,
    attachments: vec![],
    cancel_token: None,
    prior_messages: vec![],
    context_path: Some(PathBuf::from("/tmp/responses-session.jsonl")),
}).await?;

Responses support is intentionally stateless: requests always send store:false, and the harness replays the full local history each turn. When reasoning is returned, the client requests include:["reasoning.encrypted_content"] and stores the encrypted reasoning state in AssistantThinking.signature so the next turn can replay it without using previous_response_id or server-side item references.

Provider-hosted web_search is available on Responses through the explicit search policy:

use harness::WebSearchMode;

let harness = AgentLoopHarness::new(model, tools)
    .with_web_search(WebSearchMode::Native);

ToolChoice::None suppresses both function tools and hosted tools for that turn. ToolChoice::Required applies to the advertised tool set, including a hosted-only Responses turn.

Compaction policy

Compaction is opt-in and pluggable. The default policy is SummarizeCompactionStrategy: it prunes old oversized tool outputs first, then summarizes the history when it still exceeds the configured context-window threshold.

use harness::{AgentLoopHarness, CompactionPolicy};
use std::sync::Arc;

let context_window_tokens = 128_000;
let harness = AgentLoopHarness::new(model.clone(), tools).with_compaction(
    CompactionPolicy::summarizing(Arc::new(model), context_window_tokens),
);

Consumers that need different retention rules can implement CompactionStrategy and pass it through CompactionPolicy::new. The strategy receives the full Vec<ChatMessage> plus CompactionContext containing the system prompt, model client, context-window size, and current tool specs. Returned histories must keep provider invariants intact: any retained assistant tool calls still need their matching tool results, and strategies should return the original messages unchanged when they cannot safely compact.

Raw tool-call arguments

ToolInvocation includes raw_emitted_args: Option<String> for consumers that need to replay or inspect the exact streamed tool-call argument JSON emitted by the model. It is populated when a provider streams raw argument deltas and those bytes still parse to the same value as input.

Synthetic calls, restored histories that predate the field, provider paths that only expose parsed input, and invocations repaired by schema/truncation handling use None. OpenAI-compatible history projection uses the raw arguments when they are present and still match input; otherwise it falls back to serializing the structured input.

Web tools

agent-harness-rs intentionally separates harness-executed tools from provider-executed hosted tools and chooses exactly one search surface per turn.

web_fetch

web_fetch is a built-in read-only tool implemented by the harness. It fetches a known HTTP/HTTPS URL and returns readable content. It does not search the web or discover URLs.

Supported inputs:

Field Default Purpose
url required HTTP/HTTPS URL to fetch
format markdown One of markdown, text, or html
max_length 50000 Maximum returned characters, capped at 200000
timeout_ms 20000 Request timeout, capped at 60000

The tool follows redirects, rejects non-HTTP schemes and obvious binary/image content, caps downloaded bodies at 5 MiB, and returns JSON containing url, final_url, status, content_type, format, content, and truncated.

PlanApproval treats web_fetch as read-only, so planning-mode agents can use it alongside read, glob, and grep.

web_search routing

Search is disabled by default because it can incur network egress and provider charges. Enable it with an explicit mode:

Mode Behavior
Off Advertise neither native nor managed search
Auto Prefer proven native support, otherwise use a runtime web_search tool when present
Native Require provider-hosted search; fail fast when explicitly unsupported
Managed Require a runtime-provided web_search function tool

ModelClient::hosted_capability reports Supported, Unsupported, or Unknown for the client's actual API route. Official OpenAI Responses and Anthropic endpoints report native web search as supported. OpenAI-compatible Chat Completions reports unsupported. Custom endpoints report unknown, so Auto does not risk sending provider-specific hosted tools to a gateway that may reject them; Native can be used to force an attempt.

Custom ModelClient implementations must make this declaration explicitly; there is no capability default and no model-name allowlist.

use harness::{AgentLoopHarness, WebSearchMode};

let harness = AgentLoopHarness::new(model, tools)
    .with_web_search(WebSearchMode::Auto);

For managed search, compose WebSearchToolRuntime with the normal runtime. The managed runtime is separate from built-in tools so a missing search API key never creates a tool the application cannot execute:

use harness::{
    AgentLoopHarness, BraveSearchConfig, BraveSearchProvider,
    CompositeToolRuntime, WebSearchMode, WebSearchToolRuntime,
};
use std::sync::Arc;

let search = WebSearchToolRuntime::from_provider(BraveSearchProvider::new(
    BraveSearchConfig::new(std::env::var("BRAVE_API_KEY")?),
));
let tools = CompositeToolRuntime::new(Arc::new(search), Arc::new(local_tools));

let harness = AgentLoopHarness::new(model, tools)
    .with_web_search(WebSearchMode::Auto);

The managed result contract contains query, provider, normalized title/url/snippet rows, truncated, and an external_content.untrusted marker. Use web_fetch to read a selected result in full.

Silent-stop detection

If a model step ends with no tool calls and no user-visible text, the harness no longer treats it as a successful turn. Empty or whitespace-only final output with stop_reason = "end_turn" or "max_tokens" returns a model error containing silent_stop.

This catches provider/model failures where a turn would otherwise look successful while delivering no answer and taking no action.

Changelog

Every behavior change should be recorded in CHANGELOG.md before release. This project uses patch-only version bumps within the current minor line.

E2b sandbox

use harness::{AgentLoopHarness, E2bConfig, E2bToolRuntime, NativeTurnInput};

let tools = E2bToolRuntime::connect(E2bConfig::new(
    std::env::var("E2B_SANDBOX_ID").unwrap(),
    std::env::var("E2B_API_KEY").unwrap(),
)).await?;

let harness = AgentLoopHarness::new(model, tools);

Model capabilities catalog

models.dev["opencode"] is the only source of model ids, token limits, and reasoning controls. The harness has no built-in profiles or fallback limits. Initialize the catalog explicitly at process startup, then resolve and freeze a model config for each session. Unknown, deprecated, ambiguous, or unsupported configurations fail before the first model request.

The raw API JSON is cached at <cache_dir>/agent-harness-rs/models.dev.json for five minutes. A fresh cache starts immediately; a stale valid cache remains usable while one refresh runs; with no usable cache, initialization waits for models.dev and returns its error.

use harness::{ModelCatalog, ModelRequestConfig, ReasoningConfig, WireProtocol};

let catalog = ModelCatalog::initialize().await?;
let model = catalog.resolve(
    ModelRequestConfig {
        model: "deepseek-v4-pro".into(),
        max_output_tokens: 65_536,
        temperature: None,
        reasoning: ReasoningConfig::default(),
    },
    WireProtocol::OpenAiCompatible,
).await?;

// model.capabilities.limits.context drives compaction.
// model.max_output_tokens is sent on every request.

Configuration via environment variables:

Variable Default Purpose
AGENT_HARNESS_MODELS_URL https://models.dev/api.json Override the registry endpoint
AGENT_HARNESS_MODELS_CACHE_PATH <cache_dir>/agent-harness-rs/models.dev.json Relocate the disk cache

Approval modes

use harness::{YoloApproval, PlanApproval};

// Allow everything
Arc::new(YoloApproval)

// Read-only (hide bash/write/edit from model)
Arc::new(PlanApproval)

// Custom gate (e.g. ask user via UI)
struct MyApproval;
#[async_trait]
impl ApprovalGate for MyApproval {
    async fn approve(&self, inv: &ToolInvocation) -> bool {
        // prompt user, return true/false
    }
}

Shell risk policy

bash commands are normally pre-classified with a conservative static read-only checker before the approval gate runs. To temporarily bypass that checker while still blocking session-destroying hard-deny commands, set:

AGENT_HARNESS_SHELL_RISK_POLICY=relaxed

Accepted relaxed values are relaxed, lenient, and permissive. Leave the variable unset for the default strict behavior.

License

MIT