Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ea0f8ce
feat: state machine init
bobrykov Jul 23, 2026
cdc0046
feat: xai alias
bobrykov Jul 24, 2026
dd696c3
chore: gitignore upd
bobrykov Jul 24, 2026
05f0dc5
refactor: wip
bobrykov Jul 27, 2026
4807079
fix: openai multi-chunk streaming
bobrykov Jul 27, 2026
738b7a4
fix: openai multi-chunk streaming
bobrykov Jul 27, 2026
49feb28
fix: cancel signal re-arm
bobrykov Jul 27, 2026
9344b7e
fix: remove git add -A for auto_commit, use list of files
bobrykov Jul 27, 2026
11f0d34
fix: merge tool results into single MessagePart
bobrykov Jul 27, 2026
a0263d0
refactor: fallback manager, fix TOCTOU races
bobrykov Jul 27, 2026
e9e21a6
refactor: bare loop dispatch, fix parallel dispatch recovery drop
bobrykov Jul 27, 2026
42aa947
refactor: notify prefix consistency
bobrykov Jul 27, 2026
087b2dc
fix: rate limit hard stop check fix
bobrykov Jul 27, 2026
bd73813
fix: circuit breaker mutex for tool health
bobrykov Jul 27, 2026
4ab8818
fix: message accumulation, move history to machine
bobrykov Jul 27, 2026
0fbe2af
fix: gemini provider stream, token usage
bobrykov Jul 27, 2026
d0753bb
refactor: replace session start/end events with run start/end, add re…
bobrykov Jul 28, 2026
38a1803
fix: prevent infinite compation when no progress
bobrykov Jul 28, 2026
86e4775
fix: kill child git process on timeout
bobrykov Jul 28, 2026
6873b62
chore: recover guard for health state lock, cfg providers for structu…
bobrykov Jul 28, 2026
4d7b738
fix: thread LoopError through run-end notifications, Gemini usage fix
bobrykov Jul 28, 2026
331698b
fix: preserve part order in mixed text/tool chunks, run_end_reason docs
bobrykov Jul 28, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
*.swo
*~
.DS_Store
.env
233 changes: 233 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ uuid = { version = "1", features = ["v4", "serde"] }
tracing = "0.1"

reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls"], optional = true }
bytes = { version = "1", optional = true }
async-stream = "0.3"
httpdate = { version = "1", optional = true }
jsonschema = { version = "0.48", optional = true }
Expand All @@ -44,12 +45,13 @@ tool_health = []
tool_shield = ["tool_health"]

# Providers
providers = ["dep:reqwest", "dep:httpdate"]
providers = ["dep:reqwest", "dep:httpdate", "dep:bytes"]
openai = ["providers"]
anthropic = ["providers"]
ollama = ["providers", "openai"]
deepseek = ["providers", "openai"]
grok = ["providers", "openai"]
xai = ["grok"]
gemini = ["providers"]
zai = ["providers", "anthropic"]
grammar = ["providers"]
Expand Down
14 changes: 13 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: check test clippy fmt docs ci lint examples
.PHONY: check test clippy fmt docs ci lint examples e2e e2e-providers e2e-ollama

ci: fmt check clippy test docs examples

Expand All @@ -23,3 +23,15 @@ docs:

examples:
cargo build --examples --all-features

e2e: e2e-providers e2e-ollama

e2e-providers:
LOOPCTL_E2E=1 cargo test --features ollama,openai,anthropic,gemini,grok,deepseek,zai --test provider_e2e -- --nocapture --test-threads=1

e2e-ollama:
@test -n "$(OLLAMA_MODEL)" || { echo "ERROR: set OLLAMA_MODEL (e.g. make e2e-ollama OLLAMA_MODEL=qwen2.5:7b)"; exit 1; }
LOOPCTL_E2E=1 cargo test --features ollama,grammar --test constrained_decode -- --nocapture
LOOPCTL_E2E=1 cargo test --features ollama --test examples_e2e -- --nocapture
LOOPCTL_E2E=1 cargo test --features ollama --test provider_survival -- --nocapture
LOOPCTL_E2E=1 cargo test --features ollama --test structured_output -- --nocapture
28 changes: 13 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ and tool implementations; the framework handles the rest.
| [`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 |
| [`managers`](https://docs.rs/loopctl/latest/loopctl/managers/index.html) | `LoopManagers` — 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.* |
Expand Down Expand Up @@ -79,18 +79,19 @@ impl Tool for EchoTool {

```rust,no_run
use loopctl::engine::BareLoop;
use loopctl::engine::loop_core::Loop;
use loopctl::engine::core::Loop;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
use loopctl::engine::RunConfig;
use loopctl::tool::ToolRegistry;
use loopctl::config::LoopConfig;
use loopctl::config::SessionConfig;
use std::sync::Arc;

// 1. Bring your own API client (implements ApiClient trait)
# 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>> {
# fn model(&self) -> String { "llm-70b".to_string() }
# fn stream_messages(&self, _req: &loopctl::api::StreamRequest)
# -> std::pin::Pin<Box<dyn futures::Stream<Item = Result<loopctl::stream::StreamEvent, loopctl::api::error::ApiError>> + Send>> {
# unimplemented!()
# }
# }
Expand All @@ -101,16 +102,12 @@ let mut registry = ToolRegistry::new();
// registry.register(EchoTool);

// 3. Configure
let config = LoopConfig {
max_turns: 50,
model: "llm-70b".into(),
..Default::default()
};
let config = SessionConfig::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 mut agent = BareLoop::new(client, registry, config);
// let result = agent.run("Use the echo tool to say hello", &RunConfig::default()).await?;
// println!("Completed in {} turns", result.turn_count());
```

### Use the Testing Module
Expand All @@ -123,7 +120,7 @@ loopctl = { version = "0.1", features = ["testing"] }
```rust,no_run
use loopctl::testing::{MockApiClient, MockTool, test_config};
use loopctl::engine::BareLoop;
use loopctl::engine::loop_core::Loop;
use loopctl::engine::core::Loop;
use loopctl::tool::ToolRegistry;
use std::sync::Arc;

Expand Down Expand Up @@ -155,6 +152,7 @@ let agent = BareLoop::new(
| `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) |
| `xai` | No | `grok` | Alias for `grok` (xAI API client) |
| `gemini` | No | `providers` | Google Gemini API client (`provider::gemini`) |
| `zai` | No | `providers`, `anthropic` | Z.AI API client (Anthropic-compatible) |
| `grammar` | No | `providers` | Tool-call grammar providers for grammar-aware samplers (vLLM `guided_json`); enables the `Grammar` mode of `ToolConstraint` |
Expand Down
63 changes: 12 additions & 51 deletions examples/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,14 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};

use loopctl::api::ApiClient;
use loopctl::config::LoopConfig;
use loopctl::config::SessionConfig;
use loopctl::engine::BareLoop;
use loopctl::engine::loop_core::Loop;
use loopctl::engine::RunConfig;
use loopctl::engine::core::Loop;
use loopctl::observer::{LoopObserver, ToolPostContext, ToolPreContext};
use loopctl::tool::{FnTool, ToolContext, ToolOutput, ToolRegistry};
use serde_json::json;

// ==================================================
// Type alias for tool function signatures
// ==================================================

/// Shorthand for the boxed-future signature required by [`FnTool`].
type ToolFuture = std::pin::Pin<
Box<
Expand All @@ -48,10 +45,6 @@ type ToolFuture = std::pin::Pin<
>,
>;

// ==================================================
// Observer
// ==================================================

/// A simple observer that prints tool calls and responses to stderr.
struct PrintingObserver;

Expand All @@ -74,10 +67,6 @@ impl LoopObserver for PrintingObserver {
}
}

// ==================================================
// Usage
// ==================================================

fn print_usage_and_exit() -> ! {
eprintln!("No provider configured.\n");
eprintln!("Set one of:");
Expand All @@ -96,10 +85,6 @@ fn print_usage_and_exit() -> ! {
std::process::exit(1);
}

// ==================================================
// Tool functions
// ==================================================

fn echo_fn(input: serde_json::Value, _ctx: &ToolContext) -> ToolFuture {
Box::pin(async move {
let text = input
Expand Down Expand Up @@ -180,11 +165,6 @@ fn build_tools() -> ToolRegistry {
tools
}

// ==================================================
// Minimal arithmetic expression evaluator
// (recursive descent: + - * / and parentheses)
// ==================================================

#[derive(Debug, Clone)]
enum Token {
Num(f64),
Expand Down Expand Up @@ -342,17 +322,14 @@ fn parse_factor(tokens: &[Token], pos: &mut usize) -> Result<f64, String> {
}
}

// ==================================================
// REPL
// ==================================================

#[allow(dead_code)]
async fn run_repl<C: ApiClient>(client: Arc<C>) {
eprintln!("Connected to: {}\n", client.model());
println!("Type a message and press Enter. Type 'quit' to exit.\n");

// Create the agent once — conversation history persists across inputs.
let config = LoopConfig::default().with_max_turns(10);
let config = SessionConfig::default();
let run_config = RunConfig::default().with_max_turns(10);
let mut agent = BareLoop::new(client, build_tools(), config);
agent.register_observer(Arc::new(PrintingObserver));

Expand All @@ -377,8 +354,6 @@ async fn run_repl<C: ApiClient>(client: Arc<C>) {
});

let stdin = io::stdin();
let mut total_input: u64 = 0;
let mut total_output: u64 = 0;

loop {
print!("> ");
Expand All @@ -397,25 +372,19 @@ async fn run_repl<C: ApiClient>(client: Arc<C>) {
break;
}

match agent.run(input).await {
match agent.run(input, &run_config).await {
Ok(result) => {
total_input += result.input_tokens;
total_output += result.output_tokens;
// Text was already streamed live. Just print stats.
if result
.final_output
.as_deref()
.map_or(true, |s| s.is_empty())
{
if result.output.as_deref().map_or(true, |s| s.is_empty()) {
println!(" (empty response)");
}
println!(
"\n\n (turns: {}, tokens: {}+{} | total: {}+{})\n",
result.total_turns,
result.input_tokens,
result.output_tokens,
total_input,
total_output
result.turn_count(),
result.input_tokens(),
result.output_tokens(),
agent.session().total_input_tokens(),
agent.session().total_output_tokens(),
);
}
Err(e) => {
Expand All @@ -429,14 +398,6 @@ async fn run_repl<C: ApiClient>(client: Arc<C>) {
}
}

// ==================================================
// Provider detection & main
//
// Each provider produces a different concrete type, so we can't unify
// them into a single return. Instead, the `try_provider!` macro wraps
// the repeated "check env → build-or-die → run_repl → return" pattern.
// ==================================================

/// Build a client from the given expression, or print the error and exit.
macro_rules! build_or_die {
($provider:expr, $label:literal) => {{
Expand Down
15 changes: 8 additions & 7 deletions examples/echo-tool-cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@

use std::sync::Arc;

use loopctl::config::LoopConfig;
use loopctl::config::SessionConfig;
use loopctl::engine::BareLoop;
use loopctl::engine::loop_core::Loop;
use loopctl::engine::RunConfig;
use loopctl::engine::core::Loop;
use loopctl::testing::{MockApiClient, MockResponse, MockToolCall};
use loopctl::tool::{FnTool, ToolOutput, ToolRegistry};
use serde_json::json;
Expand Down Expand Up @@ -78,7 +79,7 @@ async fn main() {
);

// 3. Construct and run the loop.
let mut agent = BareLoop::new(Arc::new(client), tools, LoopConfig::default());
let mut agent = BareLoop::new(Arc::new(client), tools, SessionConfig::default());

// Ctrl-C interrupts the in-flight turn via loopctl's CancelSignal.
let cancel_signal = agent.cancel_signal();
Expand All @@ -89,11 +90,11 @@ async fn main() {
});

let result = agent
.run("Please echo something.")
.run("Please echo something.", &RunConfig::default())
.await
.expect("session should succeed");

println!("Turns: {}", result.total_turns);
println!("Tool calls: {}", result.tool_calls);
println!("Output: {}", result.final_output.unwrap_or_default());
println!("Turns: {}", result.turn_count());
println!("Tool calls: {}", result.tool_call_count());
println!("Output: {}", result.output.unwrap_or_default());
}
13 changes: 7 additions & 6 deletions examples/hello-cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@

use std::sync::Arc;

use loopctl::config::LoopConfig;
use loopctl::config::SessionConfig;
use loopctl::engine::BareLoop;
use loopctl::engine::loop_core::Loop;
use loopctl::engine::RunConfig;
use loopctl::engine::core::Loop;
use loopctl::testing::MockApiClient;
use loopctl::tool::ToolRegistry;

Expand All @@ -26,7 +27,7 @@ async fn main() {

// 2. Build the components.
let tools = ToolRegistry::new();
let config = LoopConfig::default();
let config = SessionConfig::default();

// 3. Construct the loop.
let mut agent = BareLoop::new(Arc::new(client), tools, config);
Expand All @@ -41,10 +42,10 @@ async fn main() {

// 4. Run and print the result.
let result = agent
.run("Say hello!")
.run("Say hello!", &RunConfig::default())
.await
.expect("session should succeed");

println!("Turns: {}", result.total_turns);
println!("Output: {}", result.final_output.unwrap_or_default());
println!("Turns: {}", result.turn_count());
println!("Output: {}", result.output.unwrap_or_default());
}
15 changes: 10 additions & 5 deletions examples/repl-cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
use std::io::{self, BufRead, Write};
use std::sync::Arc;

use loopctl::config::LoopConfig;
use loopctl::config::SessionConfig;
use loopctl::engine::BareLoop;
use loopctl::engine::loop_core::Loop;
use loopctl::engine::RunConfig;
use loopctl::engine::core::Loop;
use loopctl::testing::MockApiClient;
use loopctl::tool::ToolRegistry;

Expand Down Expand Up @@ -45,7 +46,11 @@ async fn main() {
let client =
MockApiClient::new("repl-model").with_text_response(&format!("You said: {input}"));

let mut agent = BareLoop::new(Arc::new(client), ToolRegistry::new(), LoopConfig::default());
let mut agent = BareLoop::new(
Arc::new(client),
ToolRegistry::new(),
SessionConfig::default(),
);

// A fresh agent per turn means a fresh CancelSignal per turn: Ctrl-C
// interrupts the current turn only, and the next prompt gets a clean
Expand All @@ -58,10 +63,10 @@ async fn main() {
}
});

match agent.run(input).await {
match agent.run(input, &RunConfig::default()).await {
Ok(result) => {
listener.abort();
let output = result.final_output.unwrap_or_default();
let output = result.output.unwrap_or_default();
writeln!(&mut stdout, "{output}\n").unwrap();
}
Err(e) => {
Expand Down
Loading