Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2ad57aa
refactor: observer events
bobrykov Jun 1, 2026
2ac4687
refactor: wip
bobrykov Jun 21, 2026
d4eef47
chore: update readme
bobrykov Jun 21, 2026
a3bd73f
refactor: bera loop
bobrykov Jun 21, 2026
0ee9940
chore: simple examples
bobrykov Jun 21, 2026
8c91ffb
chore: remove builder
bobrykov Jun 22, 2026
1418905
feat: add llm providers
bobrykov Jun 29, 2026
ea75f8d
chore: add chat example
bobrykov Jun 29, 2026
9373577
chore: replace CancelSignal with CancellationToken, fix loop detectio…
bobrykov Jun 29, 2026
11e6aa3
ix: pass user_input only on first turn, soften compaction failure, ad…
bobrykov Jun 29, 2026
6e2eacf
feat: add providers timeouts
bobrykov Jun 29, 2026
459cbd1
fix: validate max tokens
bobrykov Jun 29, 2026
60cf49e
fix: output limit middleware
bobrykov Jun 30, 2026
d04b2cc
fix: orhpaned call results in truncating compactor
bobrykov Jun 30, 2026
36ff98e
chore: max reponse size + timeout for llm providers
bobrykov Jun 30, 2026
52510f0
fix: remove gemini api key from url
bobrykov Jun 30, 2026
354e6a0
chore: non_exhaustive enums fix, missing docs
bobrykov Jun 30, 2026
7bcb1e8
fix: memory trait, config errors, doc links and clippy warnings
bobrykov Jun 30, 2026
6683442
chore: debug assert idle, config clone reference return
bobrykov Jun 30, 2026
ef6e4af
feat: model switch during runtime
bobrykov Jun 30, 2026
463338b
fix: loop detection hard stop propagation, cancellation guard in disp…
bobrykov Jun 30, 2026
664e4cb
fix: SSE multi-line data, tool panic isolation, error body cap
bobrykov Jul 1, 2026
66e26d2
fix: SSE parsing, tool panic isolation, error body cap, config valida…
bobrykov Jul 1, 2026
8d7c3f0
fix: only extend streak if previous is the same
bobrykov Jul 1, 2026
82e6b9e
fix: session end reason
bobrykov Jul 1, 2026
0c31280
chore: reject tool non ubject inputfix
bobrykov Jul 1, 2026
82da15b
fix: fail the multipart test on unexpected part variants
bobrykov Jul 1, 2026
5fd7df9
fix: enforce the limit across the multipart output
bobrykov Jul 1, 2026
f0563a7
chore: update readme
bobrykov Jul 1, 2026
0a0364b
fix: misleading docs, snapshot before retrieve memory
bobrykov Jul 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --all-features -- -D warnings
- run: cargo clippy --all-targets --all-features -- -D warnings

fmt:
runs-on: ubuntu-latest
Expand Down
33 changes: 32 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ repository = "https://github.com/dch-labs/loopctl"
homepage = "https://github.com/dch-labs/loopctl"
documentation = "https://docs.rs/loopctl"
keywords = ["agent", "framework", "llm", "loop"]
categories = ["agent", "development-tools"]
categories = ["api-bindings", "development-tools"]
readme = "README.md"
rust-version = "1.85"

Expand All @@ -23,9 +23,14 @@ serde_json = "1"
serde_repr = "0.1"
thiserror = "2"
tokio = { version = "1.52.3", features = ["sync", "macros", "time"] }
tokio-util = { version = "0.7", features = ["rt"] }
uuid = { version = "1", features = ["v4", "serde"] }
tracing = "0.1"

parking_lot = "0.12"
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"], optional = true }
async-stream = { version = "0.3", optional = true }

[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
proptest = "1"
Expand All @@ -37,6 +42,32 @@ testing = []
tool_health = []
tool_shield = ["tool_health"]

# Providers
providers = ["dep:reqwest", "dep:async-stream"]
openai = ["providers"]
anthropic = ["providers"]
ollama = ["providers", "openai"]
deepseek = ["providers", "openai"]
grok = ["providers", "openai"]
gemini = ["providers"]
zai = ["providers", "anthropic"]

[[example]]
name = "hello-cli"
required-features = ["testing"]

[[example]]
name = "repl-cli"
required-features = ["testing"]

[[example]]
name = "echo-tool-cli"
required-features = ["testing"]

[[example]]
name = "chat"
required-features = ["testing", "providers"]

[lints.clippy]
pedantic = { level = "warn", priority = -1 }
unwrap_used = "deny"
Expand Down
9 changes: 6 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.PHONY: check test clippy fmt docs ci lint
.PHONY: check test clippy fmt docs ci lint examples

ci: fmt check clippy test docs
ci: fmt check clippy test docs examples

check:
cargo check --all-features
Expand All @@ -10,7 +10,7 @@ test:
cargo test --doc --all-features

clippy:
cargo clippy --all-features -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings

fmt:
cargo fmt --all -- --check
Expand All @@ -20,3 +20,6 @@ lint:

docs:
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features

examples:
cargo build --examples --all-features
129 changes: 74 additions & 55 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,32 @@ and tool implementations; the framework handles the rest.

| Module | Description |
|--------|-------------|
| [`api_client`](https://docs.rs/loopctl/latest/loopctl/api_client/index.html) | `ApiClient` trait for LLM provider communication (streaming + non-streaming) |
| [`api_error`](https://docs.rs/loopctl/latest/loopctl/api_error/index.html) | API error types with retry classification |
| [`builder`](https://docs.rs/loopctl/latest/loopctl/builder/index.html) | Fluent builder API with type-state generics for compile-time safety |
| [`builtin`](https://docs.rs/loopctl/latest/loopctl/builtin/index.html) | Reference implementations: `InMemoryStore`, `LoggingObserver` |
| [`cancel`](https://docs.rs/loopctl/latest/loopctl/cancel/index.html) | Cooperative cancellation via `CancelSignal` (AtomicBool + tokio::Notify) |
| [`compact`](https://docs.rs/loopctl/latest/loopctl/compact/index.html) | Context compaction: `ContextCompactor` trait, `TruncatingCompactor`, `TokenSplitter` |
| [`core`](https://docs.rs/loopctl/latest/loopctl/core/index.html) | Core traits (`AgentCore`, `AgentObserver`, `AgentMemory`), config, error, and state types |
| [`engine`](https://docs.rs/loopctl/latest/loopctl/engine/index.html) | `BareLoop<C>` — the default agent loop engine (stream → accumulate → dispatch tools → repeat) |
| [`loop_control`](https://docs.rs/loopctl/latest/loopctl/loop_control/index.html)| Loop detection, convergence detection, fallback model chains, and manager bundle |
| [`message`](https://docs.rs/loopctl/latest/loopctl/message/index.html) | Conversation types: `Message`, `MessagePart`, `ToolContent`, roles |
| [`observability`](https://docs.rs/loopctl/latest/loopctl/observability/index.html) | Structured event streaming: `EventSink` trait, `ObserveEvent`, `CompositeSink`, `ConsoleSink` |
| [`stream`](https://docs.rs/loopctl/latest/loopctl/stream/index.html) | Streaming event types, accumulator, stop reasons, usage tracking |
| [`tool`](https://docs.rs/loopctl/latest/loopctl/tool/index.html) | `Tool` trait, `ToolRegistry`, `ToolSchema`, `ToolOutput`, `FnTool` adapter |
| [`testing`](https://docs.rs/loopctl/latest/loopctl/testing/index.html) | Mock API client, mock tools, and test fixture factories (feature-gated) |
| [`api`](https://docs.rs/loopctl/latest/loopctl/api/index.html) | `ApiClient` trait for LLM provider communication (streaming + non-streaming) |
| [`api::error`](https://docs.rs/loopctl/latest/loopctl/api/error/index.html) | API error types with retry classification |
| [`cancel`](https://docs.rs/loopctl/latest/loopctl/cancel/index.html) | Cooperative cancellation via `CancelSignal` (AtomicBool + tokio::Notify) |
| [`capabilities`](https://docs.rs/loopctl/latest/loopctl/capabilities/index.html) | Capability traits (`Observable`, `Detectable`, `Compactable`, etc.) |
| [`compact`](https://docs.rs/loopctl/latest/loopctl/compact/index.html) | Context compaction: `ContextCompactor` trait, `TruncatingCompactor`, `TokenSplitter` |
| [`config`](https://docs.rs/loopctl/latest/loopctl/config/index.html) | Session configuration (`LoopConfig`) |
| [`detection`](https://docs.rs/loopctl/latest/loopctl/detection/index.html) | Loop detection, convergence detection, `DetectionManager` |
| [`engine`](https://docs.rs/loopctl/latest/loopctl/engine/index.html) | `BareLoop<C>` — the default agent loop engine (stream → accumulate → dispatch tools → repeat) |
| [`error`](https://docs.rs/loopctl/latest/loopctl/error/index.html) | Central `LoopError` enum for all framework operations |
| [`fallback`](https://docs.rs/loopctl/latest/loopctl/fallback/index.html) | Circuit-breaker pattern for automatic API model fallback (`FallbackManager`) |
| [`memory`](https://docs.rs/loopctl/latest/loopctl/memory/index.html) | `LoopMemory` trait and entry types; `memory::builtin` provides `InMemoryStore` |
| [`message`](https://docs.rs/loopctl/latest/loopctl/message/index.html) | Conversation types: `Message`, `MessagePart`, `ToolContent`, roles |
| [`middleware`](https://docs.rs/loopctl/latest/loopctl/middleware/index.html) | Tool dispatch pipeline: timeouts, permissions, output limits, unknown-tool handling |
| [`observer`](https://docs.rs/loopctl/latest/loopctl/observer/index.html) | `LoopObserver` trait and `ObserverHost` for lifecycle event observation |
| [`reflection`](https://docs.rs/loopctl/latest/loopctl/reflection/index.html) | Failure reflection and recovery strategies (`Reflector`, `RecoveryStrategy`) |
| [`runtime`](https://docs.rs/loopctl/latest/loopctl/runtime/index.html) | `LoopRuntime` — the default infrastructure bundle |
| [`stream`](https://docs.rs/loopctl/latest/loopctl/stream/index.html) | Streaming event types, accumulator, stop reasons, usage tracking |
| [`tool`](https://docs.rs/loopctl/latest/loopctl/tool/index.html) | `Tool` trait, `ToolRegistry`, `ToolSchema`, `ToolOutput`, `FnTool` adapter |
| [`hooks`](https://docs.rs/loopctl/latest/loopctl/hooks/index.html) | Bidirectional lifecycle control (allow/block/ask before tool use). *Requires `hooks` feature.* |
| [`testing`](https://docs.rs/loopctl/latest/loopctl/testing/index.html) | Mock API client, mock tools, and test fixture factories. *Requires `testing` feature.* |

## Quick Start

### Implement a Tool

```rust,ignore
```rust,no_run
use loopctl::tool::{Tool, ToolContext, ToolOutput, ToolError, ToolSchema};
use serde_json::{Value, json};
use std::pin::Pin;
Expand Down Expand Up @@ -71,30 +77,40 @@ impl Tool for EchoTool {

### Run an Agent Loop

```rust,ignore
use loopctl::engine::bare::BareLoop;
```rust,no_run
use loopctl::engine::BareLoop;
use loopctl::engine::loop_core::Loop;
use loopctl::tool::ToolRegistry;
use loopctl::core::types::AgentConfig;
use loopctl::config::LoopConfig;
use std::sync::Arc;

// 1. Bring your own API client (implements ApiClient trait)
let client = Arc::new(my_provider_client);
# struct MyClient;
# use loopctl::api::ApiClient;
# impl ApiClient for MyClient {
# fn model(&self) -> &str { "llm-70b" }
# fn stream_messages(&self, _req: loopctl::api::StreamRequest)
# -> std::pin::Pin<Box<dyn futures::Stream<Item = Result<loopctl::stream::StreamEvent, loopctl::stream::StreamError>> + Send>> {
# unimplemented!()
# }
# }
let client = Arc::new(MyClient);

// 2. Register tools
let mut registry = ToolRegistry::new();
registry.register(EchoTool);
// registry.register(EchoTool);

// 3. Configure
let config = AgentConfig {
let config = LoopConfig {
max_turns: 50,
model: "gpt-4o".into(),
model: "llm-70b".into(),
..Default::default()
};

// 4. Run
let agent = BareLoop::new(client, registry, config);
let result = agent.run("Use the echo tool to say hello").await?;
println!("Completed in {} turns", result.total_turns);
// let result = agent.run("Use the echo tool to say hello").await?;
// println!("Completed in {} turns", result.total_turns);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

### Use the Testing Module
Expand All @@ -104,54 +120,57 @@ println!("Completed in {} turns", result.total_turns);
loopctl = { version = "0.1", features = ["testing"] }
```

```rust,ignore
```rust,no_run
use loopctl::testing::{MockApiClient, MockTool, test_config};
use loopctl::engine::bare::BareLoop;
use loopctl::engine::BareLoop;
use loopctl::engine::loop_core::Loop;
use loopctl::tool::ToolRegistry;
use std::sync::Arc;

let mut client = MockApiClient::new();
client.enqueue_response(/* ... */);
let mut client = MockApiClient::new("test-model");
client = client.with_text_response("Hello from the mock");

let mut registry = ToolRegistry::new();
registry.register(MockTool::new("demo"));
registry.register(MockTool::new("demo", "A demo tool"));

let agent = BareLoop::new(
client.into_shared(),
Arc::new(client),
registry,
test_config(),
);
let result = agent.run("test input").await?;
// let result = agent.run("test input").await?;
```

## Feature Flags

| Feature | Default | Description |
|-----------|---------|----------------------------------------|
| `testing` | No | Mock clients, tools, and test fixtures |
| Feature | Default | Depends on | Description |
|---------|---------|------------|-------------|
| `hooks` | No | — | Bidirectional lifecycle hooks (allow/block/ask before tool use, compaction) |
| `testing` | No | — | Mock clients, tools, and test fixtures |
| `tool_health` | No | — | Per-tool health monitoring, circuit breakers, and self-healing routing |
| `tool_shield` | No | `tool_health` | Tool permission shielding and access control |
| `providers` | No | — | Base provider support (`reqwest` + `async-stream`); enables `provider` module |
| `openai` | No | `providers` | OpenAI-compatible API client (`provider::openai`) |
| `anthropic` | No | `providers` | Anthropic Claude API client (`provider::anthropic`) |
| `ollama` | No | `providers`, `openai` | Ollama local model client (OpenAI-compatible) |
| `deepseek` | No | `providers`, `openai` | DeepSeek API client (OpenAI-compatible) |
| `grok` | No | `providers`, `openai` | Grok (xAI) API client (OpenAI-compatible) |
| `gemini` | No | `providers` | Google Gemini API client (`provider::gemini`) |
| `zai` | No | `providers`, `anthropic` | Z.AI API client (Anthropic-compatible) |

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## Architecture

```text
┌──────────────┐
│ ApiClient │ ← you implement this
└───────┬──────┘
┌─────────────▼─────────────┐
│ BareLoop │
│ ┌─────────────────────┐ │
│ │ stream → accumulate│ │
│ │ → tool dispatch │ │
│ │ → repeat │ │
│ └─────────────────────┘ │
└─────┬──────────┬──────────┘
│ │
┌────────────▼──┐ ┌────▼───────────┐
│ ToolRegistry │ │ Loop Control │
│ (your tools) │ │ • convergence │
└───────────────┘ │ • detection │
│ • fallback │
└────────────────┘
```
At the center is **BareLoop**, the default agent loop. Each turn it streams a
response from an **ApiClient** (your LLM provider), accumulates the result, and
dispatches any requested tool calls through a **ToolRegistry**. Results are fed
back into the conversation and the cycle repeats until the model ends its turn
or a configured limit is reached.

Two cross-cutting concerns run alongside the main loop:

- **Detection & Fallback** — convergence detection, loop detection, and
automatic model/API fallback when requests fail.
- **ToolRegistry** — holds your registered tools and routes tool calls to them.

## Development

Expand Down
Loading
Loading