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](https://img.shields.io/crates/v/agent-harness-rs.svg)](https://crates.io/crates/agent-harness-rs)
[![docs.rs](https://docs.rs/agent-harness-rs/badge.svg)](https://docs.rs/agent-harness-rs)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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:

| 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.

```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:

| 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

```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