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
356 changes: 168 additions & 188 deletions Cargo.lock

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ keywords = ["llm", "agent", "mcp", "podman", "sandbox"]
categories = ["command-line-utilities", "development-tools"]

[workspace.dependencies]
anyhow = { version = "1.0.102", default-features = false }
anyhow = { version = "1.0.103", default-features = false }
async-stream = { version = "0.3.6", default-features = false }
axum = { version = "0.8.9", default-features = false }
blake3 = { version = "1.8.5", default-features = false }
Expand All @@ -26,16 +26,16 @@ directories = { version = "6.0.0", default-features = false }
futures-util = { version = "0.3.32", default-features = false }
heck = { version = "0.5.0", default-features = false }
hf-hub = { version = "=0.4.3", default-features = false }
ignore = { version = "0.4.26", default-features = false }
ignore = { version = "0.4.28", default-features = false }
indexmap = { version = "2.14.0", default-features = false }
jiff = { version = "0.2.29", default-features = false }
jiff = { version = "0.2.32", default-features = false }
mistralrs-core = { version = "=0.8.1", default-features = false }
nix = { version = "0.31.3", default-features = false }
rand = { version = "0.10.1", default-features = false }
rand = { version = "0.10.2", default-features = false }
regex = { version = "1.12.4", default-features = false }
reqwest = { version = "0.13.4", default-features = false }
rig = { version = "0.39.0", default-features = false, package = "rig-core" }
rmcp = { version = "1.8.0", default-features = false }
rig = { version = "0.40.0", default-features = false, package = "rig-core" }
rmcp = { version = "2.2.0", default-features = false }
schemars = { version = "1.2.1", default-features = false }
serde = { version = "1.0.228", default-features = false }
serde_json = { version = "1.0.150", default-features = false }
Expand Down
7 changes: 7 additions & 0 deletions crates/outrig-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Upgraded the LLM/agent stack: `rig-core` 0.39 -> 0.40 and the `rmcp` MCP SDK
1.x -> 2.x, plus routine dependency bumps (anyhow, ignore, jiff, rand, toml).
rig 0.40's `max_turns` now counts total model calls rather than tool-call
rounds; outrig compensates so the per-turn tool-call limit behaves as before.

## [0.1.0](https://github.com/tgockel/outrig/releases/tag/outrig-cli-v0.1.0) - 2026-06-26

### Added
Expand Down
93 changes: 53 additions & 40 deletions crates/outrig-cli/src/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};

#[cfg(feature = "local-llm")]
use futures_util::StreamExt;
use rig::agent::{HookAction, PromptHook, ToolCallHookAction};
use rig::agent::{AgentHook, Flow, HookContext, StepEvent, StepEventKind};
#[cfg(feature = "local-llm")]
use rig::agent::{MultiTurnStreamItem, StreamingError};
use rig::completion::{CompletionModel, Message, Prompt};
#[cfg(feature = "local-llm")]
use rig::streaming::{StreamedAssistantContent, StreamingPrompt};
use rig::streaming::{StreamedAssistantContent, StreamingChat};
use thiserror::Error;
#[cfg(feature = "local-llm")]
use tokio::io::{AsyncWrite, AsyncWriteExt};
Expand All @@ -22,9 +22,13 @@ use crate::error::Result;
use crate::rig_tool::McpToolAdapter;
use outrig::config::{Config, DEFAULT_TOOL_CALL_MAX, LlmProvider, MistralrsDeviceSpec};

/// Hard max on tool calls per turn. The hook below trips this; rig's own
/// `max_turns` is set to the same value as a defense in depth, so whichever
/// fires first surfaces a controllable message.
/// Hard max on tool calls per turn. The per-turn [`OutrigPromptHook`] trips
/// this and surfaces a controllable message. rig's own `max_turns` is a
/// backstop set a little higher: as of rig 0.40 it counts *total* model calls
/// (initial completion + one continuation per tool call) rather than the looser
/// 0.39 budget, so callers pass `tool_call_max + 2` to preserve the previous
/// effective allowance and keep the hook -- not rig -- the limiter that fires
/// first.
pub const MAX_TOOL_CALLS: usize = DEFAULT_TOOL_CALL_MAX as usize;

/// Default byte ceiling applied to each individual MCP tool result before it
Expand Down Expand Up @@ -603,9 +607,9 @@ async fn run_turn_inner<M: CompletionModel + 'static>(
let hook = OutrigPromptHook::new(tool_call_max);
let result = agent
.prompt(prompt.to_string())
.with_history(history.clone())
.max_turns(tool_call_max)
.with_hook(hook)
.history(history.clone())
.max_turns(tool_call_max + 2)
.add_hook(hook)
.extended_details()
.await;

Expand Down Expand Up @@ -646,10 +650,9 @@ where
{
let hook = OutrigPromptHook::new(tool_call_max);
let mut stream = agent
.stream_prompt(prompt.to_string())
.with_history(history.clone())
.multi_turn(tool_call_max)
.with_hook(hook)
.stream_chat(prompt.to_string(), history.clone())
.max_turns(tool_call_max + 2)
.add_hook(hook)
.await;

let mut streamed_reply = String::new();
Expand All @@ -668,7 +671,7 @@ where
stdout.flush().await?;
}
Ok(MultiTurnStreamItem::FinalResponse(response)) => {
final_history = response.history().map(|messages| messages.to_vec());
final_history = response.messages().map(|messages| messages.to_vec());
}
Ok(_) => {}
Err(err) => {
Expand Down Expand Up @@ -762,36 +765,46 @@ impl OutrigPromptHook {
}
}

impl<M: CompletionModel> PromptHook<M> for OutrigPromptHook {
async fn on_completion_call(&self, _prompt: &Message, _history: &[Message]) -> HookAction {
if self.cap_reached.load(Ordering::SeqCst) {
return HookAction::terminate(format!(
"tool-call iteration max ({}) reached; ending turn",
self.max
));
}
HookAction::cont()
impl<M: CompletionModel> AgentHook<M> for OutrigPromptHook {
// The hook only acts on `CompletionCall` and `ToolCall`; narrowing
// `observes` keeps it off the per-token `TextDelta`/`ToolCallDelta` stream
// in the streaming path, where `on_event` below would just no-op anyway.
fn observes(&self, kind: StepEventKind) -> bool {
matches!(
kind,
StepEventKind::CompletionCall | StepEventKind::ToolCall
)
}

async fn on_tool_call(
&self,
tool_name: &str,
_tool_call_id: Option<String>,
_internal_call_id: &str,
args: &str,
) -> ToolCallHookAction {
let n = self.counter.fetch_add(1, Ordering::SeqCst) + 1;
if n > self.max {
self.cap_reached.store(true, Ordering::SeqCst);
return ToolCallHookAction::skip(format!(
"[outrig] tool call not executed: per-turn tool-call max ({}) \
was reached before this call could run. The user may continue \
with a fresh max; repeat the tool call if still needed.",
self.max
));
async fn on_event(&self, _ctx: &HookContext, event: StepEvent<'_, M>) -> Flow {
match event {
StepEvent::CompletionCall { .. } => {
if self.cap_reached.load(Ordering::SeqCst) {
return Flow::terminate(format!(
"tool-call iteration max ({}) reached; ending turn",
self.max
));
}
Flow::cont()
}
StepEvent::ToolCall {
tool_name, args, ..
} => {
let n = self.counter.fetch_add(1, Ordering::SeqCst) + 1;
if n > self.max {
self.cap_reached.store(true, Ordering::SeqCst);
return Flow::skip(format!(
"[outrig] tool call not executed: per-turn tool-call max ({}) \
was reached before this call could run. The user may continue \
with a fresh max; repeat the tool call if still needed.",
self.max
));
}
eprintln!("[outrig] tool call: {tool_name}({args})");
Flow::cont()
}
_ => Flow::cont(),
}
eprintln!("[outrig] tool call: {tool_name}({args})");
ToolCallHookAction::cont()
}
}

Expand Down
22 changes: 13 additions & 9 deletions crates/outrig-cli/src/mcp_self/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ use std::sync::Arc;
use rmcp::ErrorData as McpError;
use rmcp::ServerHandler;
use rmcp::model::{
CallToolRequestParams, CallToolResult, Content, Implementation, JsonObject, ListToolsResult,
PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, ToolAnnotations,
CallToolRequestParams, CallToolResult, ContentBlock, Implementation, JsonObject,
ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, ToolAnnotations,
};
use rmcp::service::{RequestContext, RoleServer};
use schemars::JsonSchema;
Expand Down Expand Up @@ -117,7 +117,7 @@ impl SelfServer {
};
match docs::get_doc(&args.page) {
Some(doc) => json_result(doc),
None => Ok(CallToolResult::error(vec![Content::text(format!(
None => Ok(CallToolResult::error(vec![ContentBlock::text(format!(
"unknown doc page: {}",
args.page
))])),
Expand Down Expand Up @@ -147,7 +147,7 @@ impl SelfServer {
};
json_result(validate::validate_image_toml(&args.toml))
}
other => Ok(CallToolResult::error(vec![Content::text(format!(
other => Ok(CallToolResult::error(vec![ContentBlock::text(format!(
"unknown tool: {other}"
))])),
}
Expand Down Expand Up @@ -226,18 +226,22 @@ fn parse_args<T: DeserializeOwned>(
arguments: Option<JsonObject>,
) -> std::result::Result<T, CallToolResult> {
serde_json::from_value(serde_json::Value::Object(arguments.unwrap_or_default())).map_err(
|err| CallToolResult::error(vec![Content::text(format!("invalid arguments: {err}"))]),
|err| {
CallToolResult::error(vec![ContentBlock::text(format!(
"invalid arguments: {err}"
))])
},
)
}

fn json_result<T: Serialize>(value: T) -> std::result::Result<CallToolResult, McpError> {
Ok(CallToolResult::success(vec![Content::json(value)?]))
Ok(CallToolResult::success(vec![ContentBlock::json(value)?]))
}

#[cfg(test)]
mod tests {
use super::*;
use rmcp::model::RawContent;
use rmcp::model::ContentBlock;
use serde_json::json;

fn call(name: &str, args: serde_json::Value) -> CallToolResult {
Expand All @@ -254,8 +258,8 @@ mod tests {
}

fn text(result: &CallToolResult) -> &str {
match &result.content[0].raw {
RawContent::Text(t) => &t.text,
match &result.content[0] {
ContentBlock::Text(t) => &t.text,
other => panic!("expected text content, got {other:?}"),
}
}
Expand Down
15 changes: 6 additions & 9 deletions crates/outrig-cli/src/rig_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

use std::sync::Arc;

use rig::completion::ToolDefinition;
use rig::tool::{ToolDyn, ToolError};
use rig::wasm_compat::WasmBoxedFuture;
use serde_json::Value;
Expand Down Expand Up @@ -142,14 +141,12 @@ impl ToolDyn for McpToolAdapter {
self.openai_name.clone()
}

fn definition(&self, _prompt: String) -> WasmBoxedFuture<'_, ToolDefinition> {
Box::pin(async move {
ToolDefinition {
name: self.openai_name.clone(),
description: self.description.clone(),
parameters: self.input_schema.clone(),
}
})
fn description(&self) -> String {
self.description.clone()
}

fn parameters(&self) -> Value {
self.input_schema.clone()
}

fn call(&self, args: String) -> WasmBoxedFuture<'_, std::result::Result<String, ToolError>> {
Expand Down
6 changes: 3 additions & 3 deletions crates/outrig-cli/tests/mcp_self.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use std::process::Stdio;
use std::sync::{Arc, Mutex};
use std::time::Duration;

use rmcp::model::{CallToolRequestParams, RawContent};
use rmcp::model::{CallToolRequestParams, ContentBlock};
use rmcp::service::serve_client;
use serde::de::DeserializeOwned;
use serde_json::Value;
Expand Down Expand Up @@ -240,8 +240,8 @@ where
let body = result
.content
.iter()
.filter_map(|content| match &content.raw {
RawContent::Text(t) => Some(t.text.as_str()),
.filter_map(|content| match content {
ContentBlock::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
Expand Down
4 changes: 2 additions & 2 deletions crates/outrig-cli/tests/mcp_sidecar_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,8 @@ async fn sidecar_hosts_servers_with_labels_record_and_clean_reap() {
let body = call
.content
.iter()
.filter_map(|c| match &c.raw {
rmcp::model::RawContent::Text(t) => Some(t.text.as_str()),
.filter_map(|c| match c {
rmcp::model::ContentBlock::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
Expand Down
4 changes: 2 additions & 2 deletions crates/outrig-cli/tests/mcp_subcommand_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -427,8 +427,8 @@ async fn mcp_subcommand_serves_namespaced_tools_and_exits_clean() {
let body = call
.content
.iter()
.filter_map(|c| match &c.raw {
rmcp::model::RawContent::Text(t) => Some(t.text.as_str()),
.filter_map(|c| match c {
rmcp::model::ContentBlock::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
Expand Down
6 changes: 6 additions & 0 deletions crates/outrig/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Upgraded the `rmcp` MCP SDK from 1.x to 2.x. The proxy and client now build on
rmcp 2.2's flat `ContentBlock` content model (replacing `RawContent`), so
consumers of this library link against rmcp 2.x.

## [0.1.0](https://github.com/tgockel/outrig/releases/tag/outrig-v0.1.0) - 2026-06-26

### Added
Expand Down
16 changes: 9 additions & 7 deletions crates/outrig/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use std::path::Path;
use std::process::Stdio;
use std::time::Duration;

use rmcp::model::{CallToolRequestParams, RawContent, ResourceContents};
use rmcp::model::{CallToolRequestParams, ContentBlock, ResourceContents};
use rmcp::service::{RoleClient, RunningService, serve_client};
use serde_json::Value;
use tokio::process::Child;
Expand Down Expand Up @@ -270,17 +270,17 @@ impl McpClient {
if i > 0 {
content_text.push('\n');
}
match &content.raw {
RawContent::Text(t) => content_text.push_str(&t.text),
RawContent::Image(img) => {
match content {
ContentBlock::Text(t) => content_text.push_str(&t.text),
ContentBlock::Image(img) => {
let _ = write!(
content_text,
"[image: {}, {} base64 bytes]",
img.mime_type,
img.data.len()
);
}
RawContent::Resource(r) => match &r.resource {
ContentBlock::Resource(r) => match &r.resource {
ResourceContents::TextResourceContents { text, .. } => {
content_text.push_str(text)
}
Expand All @@ -290,18 +290,20 @@ impl McpClient {
let mime = mime_type.as_deref().unwrap_or("application/octet-stream");
let _ = write!(content_text, "[blob: {mime}, {} base64 bytes]", blob.len());
}
_ => content_text.push_str("[unsupported resource contents]"),
},
RawContent::Audio(audio) => {
ContentBlock::Audio(audio) => {
let _ = write!(
content_text,
"[audio: {}, {} base64 bytes]",
audio.mime_type,
audio.data.len()
);
}
RawContent::ResourceLink(link) => {
ContentBlock::ResourceLink(link) => {
let _ = write!(content_text, "[resource link: {}]", link.uri);
}
_ => content_text.push_str("[unsupported content block]"),
}
}

Expand Down
Loading
Loading