# agent-harness-rs
[](https://crates.io/crates/agent-harness-rs)
[](https://docs.rs/agent-harness-rs)
[](LICENSE)
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 tools** — `bash`, `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 integration** — `E2bToolRuntime` 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
```toml
[dependencies]
agent-harness-rs = "0.2"
# For e2b sandbox support:
agent-harness-rs = { version = "0.2", features = ["e2b"] }
```
```rust
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.
```rust
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:
```rust
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.
```rust
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:
| `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:
| `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.
```rust
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:
```rust
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
```rust
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.
```rust
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:
| `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
```rust
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:
```sh
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