Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/2.0.0.
- `ApiClient::base_url()` trait method (default `""`), overridden by the
OpenAI, Anthropic, and Gemini clients to expose their configured endpoint for
per-provider bucket keying.
- `ParallelMode` + `ParallelDispatchConfig` in `config.rs`: opt-in parallel
tool dispatch for independent, concurrency-safe calls within a single turn.
`LoopConfig` is now `#[non_exhaustive]`; the new `parallel_tool_dispatch`
field defaults to `Sequential` (v0.1.0 behaviour unchanged).
- `Tool::resource_key(&self, &Value) -> Option<String>` trait method (default
`None`) for parallel-dispatch resource-conflict detection, plus the
`FnTool::with_resource_key` builder.
- `MockTool::with_delay(Duration)` builder for timing-sensitive tests.

### Changed

- Both sequential and parallel tool dispatch now check the cancel signal
between calls. Previously, a Ctrl-C during a multi-tool batch was only
honored at the next turn boundary; now it aborts the remaining calls in the
batch.

## [0.1.0] - 2025-07-01

Expand Down
5 changes: 1 addition & 4 deletions examples/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,10 +352,7 @@ async fn run_repl<C: ApiClient>(client: Arc<C>) {
println!("Type a message and press Enter. Type 'quit' to exit.\n");

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

Expand Down
241 changes: 227 additions & 14 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,30 +23,90 @@ use uuid::Uuid;
/// ```
/// use loopctl::config::LoopConfig;
///
/// let config = LoopConfig {
/// max_turns: 50,
/// model: "default".to_string(),
/// ..Default::default()
/// };
/// let config = LoopConfig::default()
/// .with_max_turns(50)
/// .with_model("default");
/// ```
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct LoopConfig {
/// Unique session identifier (random UUID v4).
/// Unique session identifier.
///
/// A random UUID v4 generated on construction. Used to tag tool contexts,
/// session save/load paths, and observer events so a host app can correlate
/// turns across logs and persisted state. Two configs with the same
/// `session_id` are considered the same logical session.
pub session_id: Uuid,
/// Model identifier (e.g. `"default"`). Passed to the API client on each request.

/// Model identifier passed to the API client on each request.
///
/// Provider-specific (e.g. `"gpt-4o"`, `"claude-3.5-sonnet"`). The client
/// may override this at runtime via
/// [`ApiClient::set_model`](crate::api::ApiClient::set_model) when the
/// [`FallbackManager`](crate::fallback::FallbackManager) trips. Must be
/// non-empty ([`validate`](LoopConfig::validate) rejects whitespace-only).
pub model: String,
/// Optional system prompt override. `None` means the agent core decides.

/// Optional system prompt override.
///
/// When `None`, the agent core assembles its own system prompt from the
/// registered tools' `system_prompt()` contributions. When `Some(text)`,
/// that text replaces the default entirely. Set this to inject a custom
/// persona or instruction set.
pub system_prompt: Option<String>,
/// Maximum number of turns before forcing completion. Defaults to `200`.

/// Maximum number of turns before forcing completion.
///
/// A safety cap: the agent loop halts with
/// [`LoopError::MaxTurnsExceeded`](crate::error::LoopError::MaxTurnsExceeded)
/// if it reaches this count without the model emitting a stop. Defaults to
/// `200`. Must be at least `1` ([`validate`](LoopConfig::validate)).
pub max_turns: usize,
/// Maximum tokens for each API response. Defaults to `16_384`.

/// Maximum tokens for each API response.
///
/// Sent to the provider as the `max_tokens` parameter on every request,
/// capping the length of a single model response. Defaults to `16_384`.
/// Must be at least `1` ([`validate`](LoopConfig::validate)).
pub max_tokens: u32,
/// Context window size in tokens. Must match the actual window of [`model`](LoopConfig::model). Defaults to `200_000`.

/// Context window size in tokens.
///
/// Must match the actual window of [`model`](LoopConfig::model). Used by
/// the compaction subsystem to decide when conversation history exceeds
/// the budget (see [`compact_threshold`](LoopConfig::compact_threshold)).
/// Defaults to `200_000`. Must be at least `1`
/// ([`validate`](LoopConfig::validate)).
pub context_window: u64,
/// Threshold to trigger auto-compaction (0.0–1.0). Defaults to `0.80`.

/// Threshold to trigger auto-compaction, as a fraction of the context
/// window (0.0–1.0).
///
/// When the estimated token usage of the conversation exceeds
/// `compact_threshold * context_window`, the compactor runs before the next
/// turn to avoid an overflow. Defaults to `0.80`. Must be finite and in
/// `[0.0, 1.0]` ([`validate`](LoopConfig::validate)).
pub compact_threshold: f64,
/// Whether auto-compaction is enabled. Defaults to `true`.
/// Whether auto-compaction is enabled.
///
/// When `true` (the default), the compactor runs automatically when
/// [`compact_threshold`](LoopConfig::compact_threshold) is reached. When
/// `false`, the agent never auto-compacts — the host app must manage
/// context size manually (useful for tests or fixed-length sessions).
pub auto_compact: bool,

/// How independent tool calls within a single turn are dispatched.
///
/// Defaults to [`ParallelMode::Sequential`] (one at a time).
/// Set [`ParallelMode::Parallel`] to run independent,
/// concurrency-safe calls concurrently up to
/// [`max_concurrency`](ParallelDispatchConfig::max_concurrency).
///
/// Parallel mode changes observer/detection event ordering (PRE events are
/// batched, then POST events batched, rather than strictly paired per
/// call) and adds finer-grained cancellation. See
/// [`ParallelDispatchConfig`] and the dispatch module docs.
pub parallel_tool_dispatch: ParallelDispatchConfig,
}

impl Default for LoopConfig {
Expand All @@ -62,6 +122,7 @@ impl Default for LoopConfig {
/// | [`context_window`](LoopConfig::context_window) | `200_000` |
/// | [`compact_threshold`](LoopConfig::compact_threshold) | `0.80` |
/// | [`auto_compact`](LoopConfig::auto_compact) | `true` |
/// | [`parallel_tool_dispatch`](LoopConfig::parallel_tool_dispatch) | `Sequential`, `max_concurrency: 8` |
///
/// # Example
///
Expand All @@ -82,11 +143,75 @@ impl Default for LoopConfig {
context_window: 200_000,
compact_threshold: 0.80,
auto_compact: true,
parallel_tool_dispatch: ParallelDispatchConfig::default(),
}
}
}

impl LoopConfig {
/// Set the session ID.
#[must_use]
pub fn with_session_id(mut self, session_id: Uuid) -> Self {
self.session_id = session_id;
self
}

/// Set the model identifier.
#[must_use]
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}

/// Set the system prompt.
#[must_use]
pub fn with_system_prompt(mut self, system_prompt: impl Into<String>) -> Self {
self.system_prompt = Some(system_prompt.into());
self
}

/// Set the maximum number of turns.
#[must_use]
pub fn with_max_turns(mut self, max_turns: usize) -> Self {
self.max_turns = max_turns;
self
}

/// Set the maximum tokens per API response.
#[must_use]
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
self.max_tokens = max_tokens;
self
}

/// Set the context window size in tokens.
#[must_use]
pub fn with_context_window(mut self, context_window: u64) -> Self {
self.context_window = context_window;
self
}

/// Set the auto-compaction threshold (0.0–1.0).
#[must_use]
pub fn with_compact_threshold(mut self, compact_threshold: f64) -> Self {
self.compact_threshold = compact_threshold;
self
}

/// Enable or disable auto-compaction.
#[must_use]
pub fn with_auto_compact(mut self, auto_compact: bool) -> Self {
self.auto_compact = auto_compact;
self
}

/// Set the parallel tool dispatch policy.
#[must_use]
pub fn with_parallel_tool_dispatch(mut self, config: ParallelDispatchConfig) -> Self {
self.parallel_tool_dispatch = config;
self
}

/// Validate the configuration fields.
///
/// Checks that:
Expand All @@ -109,7 +234,7 @@ impl LoopConfig {
/// let config = LoopConfig::default();
/// assert!(config.validate().is_ok());
///
/// let bad = LoopConfig { compact_threshold: 1.5, ..config };
/// let bad = LoopConfig::default().with_compact_threshold(1.5);
/// assert!(bad.validate().is_err());
/// ```
#[must_use = "validation errors should not be silently ignored"]
Expand Down Expand Up @@ -143,10 +268,98 @@ impl LoopConfig {
self.compact_threshold
)));
}
if self.parallel_tool_dispatch.max_concurrency == 0 {
return Err(crate::error::LoopError::Config(
"parallel_tool_dispatch.max_concurrency must be at least 1".to_string(),
));
}
Ok(())
}
}

/// How independent tool calls within a single turn are dispatched.
///
/// Defaults to [`Sequential`](ParallelMode::Sequential);
/// opt into [`Parallel`](ParallelMode::Parallel)
/// via [`LoopConfig::parallel_tool_dispatch`].
///
/// Parallel mode runs independent, concurrency-safe calls concurrently (up to
/// [`ParallelDispatchConfig::max_concurrency`]). Sequential mode dispatches one
/// call at a time. Both modes check the cancel signal between calls.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ParallelMode {
/// Dispatch tool calls one at a time.
///
/// Each call in a turn runs to completion before the next begins.
/// Observers see strictly paired `on_tool_pre` / `on_tool_post`
/// events in call order, and the loop detector sees the classic
/// `[pre A, post A, pre B, post B, …]` interleaving.
/// Choose this when call ordering matters or when
/// concurrency is unnecessary (e.g. single-call turns, write-heavy
/// workloads).
Sequential,

/// Dispatch independent, concurrency-safe calls concurrently.
///
/// Calls that are safe for concurrent execution (per
/// [`Tool::is_safe_for_concurrent_execution`](crate::tool::Tool::is_safe_for_concurrent_execution))
/// run in parallel up to
/// [`max_concurrency`](ParallelDispatchConfig::max_concurrency), while
/// non-safe calls and resource-conflicting calls are serialized into
/// separate waves. Observers see batched PRE events (all `on_tool_pre`
/// in input order) then batched POST events — see the dispatch module
/// docs for the ordering invariant. Choose this for read-heavy,
/// multi-call turns where latency is the sum of independent operations.
Parallel,
}

/// Configuration for parallel tool dispatch.
///
/// Defaults to [`ParallelMode::Sequential`] (off) with a
/// `max_concurrency` of 8. Parallel dispatch is strictly opt-in — set
/// [`mode`](Self::mode) to [`Parallel`](ParallelMode::Parallel) to enable.
///
/// # Example
///
/// ```
/// use loopctl::config::{LoopConfig, ParallelDispatchConfig, ParallelMode};
///
/// let config = LoopConfig::default().with_parallel_tool_dispatch(
/// ParallelDispatchConfig { mode: ParallelMode::Parallel, max_concurrency: 4 },
/// );
/// ```
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ParallelDispatchConfig {
/// Sequential vs parallel dispatch.
///
/// Defaults to [`ParallelMode::Sequential`].
/// Set to [`ParallelMode::Parallel`] to enable concurrent dispatch of
/// independent, concurrency-safe tool calls.
///
/// See [`ParallelMode`] for the observer/detection ordering
/// implications of each variant.
pub mode: ParallelMode,

/// Maximum number of tool calls executing at once under parallel mode.
///
/// Defaults to `8`. Clamped to `[1, eligible_count]` at dispatch time (a
/// 3-call batch never tries to acquire 8 permits). Setting this to `1`
/// makes "parallel" mode behave like sequential — useful for debugging
/// (same code path, no concurrency). Must be at least 1
/// ([`LoopConfig::validate`] rejects `0`).
pub max_concurrency: usize,
}

impl Default for ParallelDispatchConfig {
fn default() -> Self {
Self {
mode: ParallelMode::Sequential,
max_concurrency: 8,
}
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading