use std::sync::Arc;
use rx4::provider::{
Message, Provider as Rx4Provider, ProviderError as Rx4ProviderError, Role, StreamEvent,
};
use crate::agent::hooks::{run_post_hooks, run_pre_hooks, HookDecision, ToolHook};
use crate::agent::stream::{emit, AgentStreamEvent, AgentStreamTx};
use crate::plugin::{HookManager, LifecycleEvent, PluginRegistry};
use crate::providers::{ChatMessage, ChatRequest, Provider as UnthinkclawProvider};
use crate::tools::{Tool as UnthinkclawTool, ToolResult as UnthinkclawToolResult, ToolSpec};
#[derive(Clone, Default)]
pub struct ToolHookContext {
hooks: Vec<Arc<dyn ToolHook>>,
plugins: Option<Arc<tokio::sync::RwLock<PluginRegistry>>>,
hook_manager: Option<Arc<HookManager>>,
stream: Option<AgentStreamTx>,
}
impl ToolHookContext {
pub fn new(
hooks: Vec<Arc<dyn ToolHook>>,
plugins: Option<Arc<tokio::sync::RwLock<PluginRegistry>>>,
) -> Self {
Self {
hooks,
plugins,
hook_manager: None,
stream: None,
}
}
pub fn with_hook_manager(mut self, hook_manager: Arc<HookManager>) -> Self {
self.hook_manager = Some(hook_manager);
self
}
pub fn with_stream(mut self, stream: Option<AgentStreamTx>) -> Self {
self.stream = stream;
self
}
async fn emit_lifecycle(&self, event: LifecycleEvent) {
if let Some(manager) = &self.hook_manager {
manager.emit(&event).await;
}
}
pub async fn check_pre_tool(&self, name: &str, arguments: &str) -> HookDecision {
if let Some(plugins) = &self.plugins {
let registry = plugins.read().await;
if let HookDecision::Block(reason) = registry.check_pre_tool(name, arguments).await {
return HookDecision::Block(format!("Blocked by plugin: {reason}"));
}
}
match run_pre_hooks(&self.hooks, name, arguments).await {
HookDecision::Block(reason) => {
HookDecision::Block(format!("Blocked by policy: {reason}"))
}
HookDecision::Allow => HookDecision::Allow,
}
}
pub async fn notify_post_tool(
&self,
name: &str,
arguments: &str,
result: &UnthinkclawToolResult,
) {
run_post_hooks(&self.hooks, name, arguments, result).await;
self.emit_lifecycle(LifecycleEvent::AfterToolCall(
name.to_string(),
arguments.to_string(),
result.clone(),
))
.await;
if let Some(plugins) = &self.plugins {
let registry = plugins.read().await;
registry.notify_post_tool(name, arguments, result).await;
}
}
}
pub async fn execute_tool_with_hooks(
ctx: &ToolHookContext,
name: &str,
arguments: &str,
tool: Option<&Arc<dyn UnthinkclawTool>>,
) -> UnthinkclawToolResult {
ctx.emit_lifecycle(LifecycleEvent::BeforeToolCall(
name.to_string(),
arguments.to_string(),
))
.await;
emit(
&ctx.stream,
AgentStreamEvent::ToolStart {
name: name.to_string(),
hint: crate::agent::loop_runner::extract_tool_hint(name, arguments),
},
);
let started = std::time::Instant::now();
let result = match ctx.check_pre_tool(name, arguments).await {
HookDecision::Block(reason) => {
tracing::info!("blocked '{}': {}", name, reason);
UnthinkclawToolResult::error(reason)
}
HookDecision::Allow => match tool {
Some(tool) => match tool.execute(arguments).await {
Ok(result) => result,
Err(e) => UnthinkclawToolResult::error(crate::redaction::redact_text(&format!(
"Tool error: {e}"
))),
},
None => UnthinkclawToolResult::error(format!("Unknown tool: {name}")),
},
};
ctx.notify_post_tool(name, arguments, &result).await;
emit(
&ctx.stream,
AgentStreamEvent::ToolEnd {
name: name.to_string(),
ok: !result.is_error,
elapsed_secs: started.elapsed().as_secs(),
},
);
result
}
pub fn chat_message_to_rx4(msg: &ChatMessage) -> Message {
let role = match msg.role.as_str() {
"system" => Role::System,
"user" => Role::User,
"assistant" | "assistant_tool_use" => Role::Assistant,
"tool_result" => Role::Tool,
_ => Role::User,
};
Message {
role,
content: msg.content.clone(),
tool_call_id: msg.tool_use_id.clone(),
tool_calls: Vec::new(),
}
}
pub fn rx4_message_to_chat(msg: &Message) -> ChatMessage {
let role = match msg.role {
Role::System => "system",
Role::User => "user",
Role::Assistant => "assistant",
Role::Tool => "tool_result",
};
ChatMessage {
role: role.to_string(),
content: msg.content.clone(),
tool_use_id: msg.tool_call_id.clone(),
}
}
pub struct RotaryProviderAdapter {
inner: Arc<dyn UnthinkclawProvider>,
id: String,
name: String,
}
impl RotaryProviderAdapter {
pub fn new(provider: Arc<dyn UnthinkclawProvider>) -> Self {
let id = provider.name().to_string();
let name = format!("apollo-{}", provider.name());
Self {
inner: provider,
id,
name,
}
}
}
#[async_trait::async_trait]
impl Rx4Provider for RotaryProviderAdapter {
fn id(&self) -> &str {
&self.id
}
fn name(&self) -> &str {
&self.name
}
async fn stream(
&self,
messages: &[Message],
system: &Option<String>,
model: &str,
tools: &[serde_json::Value],
_reasoning_effort: Option<&str>,
) -> Result<rx4::provider::StreamResult, Rx4ProviderError> {
let mut chat_messages: Vec<ChatMessage> = Vec::new();
if let Some(sys) = system {
chat_messages.push(ChatMessage::system(sys));
}
for msg in messages {
chat_messages.push(rx4_message_to_chat(msg));
}
let tool_specs: Vec<ToolSpec> = tools
.iter()
.filter_map(|t| {
let name = t.get("name")?.as_str()?.to_string();
let description = t
.get("description")
.and_then(|d| d.as_str())
.unwrap_or("")
.to_string();
let parameters = t
.get("parameters")
.cloned()
.unwrap_or(serde_json::Value::Null);
Some(ToolSpec {
name,
description,
parameters,
})
})
.collect();
let tool_refs: &[ToolSpec] = if tool_specs.is_empty() {
&[]
} else {
&tool_specs
};
let request = ChatRequest {
messages: &chat_messages,
tools: if tool_refs.is_empty() {
None
} else {
Some(tool_refs)
},
model,
temperature: 0.7,
max_tokens: Some(8192),
};
let response = self
.inner
.chat(&request)
.await
.map_err(|e| Rx4ProviderError::Api(e.to_string()))?;
let text = response.text.unwrap_or_default();
let tool_calls = response.tool_calls;
let events: Vec<Result<StreamEvent, Rx4ProviderError>> = {
let mut evs = Vec::new();
if !text.is_empty() {
evs.push(Ok(StreamEvent::Delta(text)));
}
for tc in tool_calls {
evs.push(Ok(StreamEvent::ToolCall(rx4::ToolCall {
id: tc.id,
name: tc.name,
arguments: tc.arguments,
})));
}
evs.push(Ok(StreamEvent::Done));
evs
};
use futures_util::stream;
Ok(Box::new(Box::pin(stream::iter(events))))
}
}
pub fn register_apollo_tools(
registry: &mut rx4::ToolRegistry,
tools: &[Arc<dyn UnthinkclawTool>],
hook_ctx: &ToolHookContext,
) {
use rx4::guardrails::classify_tool;
use rx4::{ToolDefinition, ToolEffect, ToolExecuteBox};
for tool in tools {
let spec = tool.spec();
let name = spec.name.clone();
let description = spec.description.clone();
let parameters_json = serde_json::to_string(&spec.parameters).unwrap_or_default();
let tool_clone = Arc::clone(tool);
let hook_ctx = hook_ctx.clone();
let tool_name = name.clone();
let execute: ToolExecuteBox = Box::new(move |_ctx, args| {
let tool = Arc::clone(&tool_clone);
let hook_ctx = hook_ctx.clone();
let tool_name = tool_name.clone();
Box::pin(async move {
let result =
execute_tool_with_hooks(&hook_ctx, &tool_name, &args, Some(&tool)).await;
rx4::ToolResult {
id: String::new(),
content: result.output,
is_error: result.is_error,
}
})
});
let effect = match classify_tool(&name) {
rx4::guardrails::ToolClass::Idempotent => ToolEffect::Read,
rx4::guardrails::ToolClass::Mutating => ToolEffect::Write,
};
registry.register(
ToolDefinition::new_boxed(name, description, parameters_json, execute)
.with_effect(effect),
);
}
}
pub struct RotaryBridgeConfig {
pub provider: Arc<dyn UnthinkclawProvider>,
pub tools: Vec<Arc<dyn UnthinkclawTool>>,
pub system_prompt: String,
pub model: String,
pub workspace: std::path::PathBuf,
pub max_tool_iterations: usize,
pub auto_compact_after: usize,
pub hook_ctx: ToolHookContext,
}
pub struct RotaryAgentBridge {
agent: rx4::Agent,
hook_ctx: ToolHookContext,
messages: Vec<Message>,
}
impl RotaryAgentBridge {
pub fn new(config: RotaryBridgeConfig) -> Self {
let rx4_provider = Arc::new(RotaryProviderAdapter::new(config.provider));
let mut agent = rx4::Agent::new();
agent.set_model(&config.model);
agent.set_system_prompt(&config.system_prompt);
agent.set_provider(rx4_provider);
agent.set_workspace_root(&config.workspace);
agent.max_tool_iterations = config.max_tool_iterations;
agent.auto_compact_after = config.auto_compact_after;
agent.set_policy(rx4::Policy::full_access());
let mut tool_registry = rx4::ToolRegistry::new();
register_apollo_tools(&mut tool_registry, &config.tools, &config.hook_ctx);
agent.tools = Arc::new(tool_registry);
Self {
agent,
hook_ctx: config.hook_ctx,
messages: Vec::new(),
}
}
pub fn agent(&self) -> &rx4::Agent {
&self.agent
}
pub fn agent_mut(&mut self) -> &mut rx4::Agent {
&mut self.agent
}
pub fn clear_messages(&mut self) {
self.messages.clear();
self.agent.clear_messages();
}
pub fn message_count(&self) -> usize {
self.messages.len()
}
pub fn set_model(&mut self, model: &str) {
self.agent.set_model(model);
}
pub fn set_system_prompt(&mut self, prompt: &str) {
self.agent.set_system_prompt(prompt);
}
pub fn set_workspace_root(&mut self, path: &std::path::Path) {
self.agent.set_workspace_root(path);
}
pub fn set_scope(&mut self, scope: rx4::Scope) {
self.agent.set_scope(scope);
}
pub fn subscribe(&mut self, callback: impl Fn(&rx4::Event) + Send + Sync + 'static) {
self.agent.subscribe(callback);
}
pub async fn run_prompt(&mut self, prompt: &str) -> anyhow::Result<String> {
let last_response = Arc::new(parking_lot::RwLock::new(String::new()));
let last_response_clone = Arc::clone(&last_response);
self.agent.subscribe(move |event| {
if let rx4::Event::MessageEnd {
content,
role: Role::Assistant,
} = event
{
*last_response_clone.write() = content.clone();
}
});
self.agent.prompt(prompt).await?;
let response = last_response.read().clone();
Ok(response)
}
pub async fn run_prompt_with_history(
&mut self,
prompt: &str,
history: &[ChatMessage],
) -> anyhow::Result<String> {
self.agent.clear_messages();
for msg in history {
let rx4_msg = chat_message_to_rx4(msg);
self.agent.messages.write().push(rx4_msg);
}
self.run_prompt(prompt).await
}
pub fn register_tools(&mut self, tools: &[Arc<dyn UnthinkclawTool>]) {
if let Some(registry) = Arc::get_mut(&mut self.agent.tools) {
register_apollo_tools(registry, tools, &self.hook_ctx);
} else {
tracing::warn!("cannot register rx4 tools while the registry is shared");
}
}
pub fn list_tools(&self) -> Vec<String> {
self.agent
.tools
.definitions()
.iter()
.filter_map(|d| {
d.get("name")
.and_then(|n| n.as_str())
.map(|s| s.to_string())
})
.collect()
}
pub fn compact(&mut self, reason: &str) {
self.agent.compact(reason);
}
pub fn messages_handle(&self) -> Arc<parking_lot::RwLock<Vec<Message>>> {
self.agent.messages_handle()
}
pub fn enable_skill_engine(&mut self, workspace: &std::path::Path) {
let mut engine = build_rx4_skill_engine(workspace);
if let Err(error) = engine.load() {
tracing::warn!("rx4 skill engine load failed, leaving it unset: {error}");
return;
}
self.agent.set_skill_engine(engine);
}
pub fn enable_graph_memory(&mut self, workspace: &std::path::Path, auto_dream: bool) {
self.agent
.set_graph_memory(rx4::GraphMemory::from_workspace(workspace));
self.agent.enable_auto_dream(auto_dream);
}
}
pub fn build_rx4_skill_engine(workspace: &std::path::Path) -> rx4::SkillEngine {
let home = dirs::home_dir().unwrap_or_default();
let managed_dir = workspace.join(".apollo/skills");
let mut engine = rx4::SkillEngine::new(managed_dir);
let openclaw_skills = home.join(".npm-global/lib/node_modules/openclaw/skills");
engine.add_extra_dir(openclaw_skills);
let shared_skills = home.join(".openclaw/workspace/skills");
engine.add_extra_dir(shared_skills);
engine
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chat_message_to_rx4_system() {
let msg = ChatMessage::system("hello");
let rx4_msg = chat_message_to_rx4(&msg);
assert_eq!(rx4_msg.role, Role::System);
assert_eq!(rx4_msg.content, "hello");
}
#[test]
fn test_chat_message_to_rx4_user() {
let msg = ChatMessage::user("test");
let rx4_msg = chat_message_to_rx4(&msg);
assert_eq!(rx4_msg.role, Role::User);
assert_eq!(rx4_msg.content, "test");
}
#[test]
fn test_chat_message_to_rx4_tool_result() {
let msg = ChatMessage::tool_result("tc_123", "result text");
let rx4_msg = chat_message_to_rx4(&msg);
assert_eq!(rx4_msg.role, Role::Tool);
assert_eq!(rx4_msg.content, "result text");
assert_eq!(rx4_msg.tool_call_id.as_deref(), Some("tc_123"));
}
#[test]
fn test_rx4_message_to_chat() {
let msg = Message::assistant("hello back");
let chat_msg = rx4_message_to_chat(&msg);
assert_eq!(chat_msg.role, "assistant");
assert_eq!(chat_msg.content, "hello back");
}
#[test]
fn test_roundtrip_translation() {
let original = ChatMessage::user("roundtrip test");
let rx4_msg = chat_message_to_rx4(&original);
let back = rx4_message_to_chat(&rx4_msg);
assert_eq!(back.role, "user");
assert_eq!(back.content, "roundtrip test");
}
#[test]
fn test_build_rx4_skill_engine() {
let tmp = tempfile::tempdir().unwrap();
let engine = build_rx4_skill_engine(tmp.path());
assert!(
engine.skills_dir().exists()
|| engine.skills_dir() == tmp.path().join(".apollo/skills")
);
}
struct RecordingTool {
ran: Arc<std::sync::atomic::AtomicBool>,
}
#[async_trait::async_trait]
impl UnthinkclawTool for RecordingTool {
fn name(&self) -> &str {
"exec"
}
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "exec".to_string(),
description: "test tool".to_string(),
parameters: serde_json::json!({"type": "object"}),
}
}
async fn execute(&self, _arguments: &str) -> anyhow::Result<UnthinkclawToolResult> {
self.ran.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(UnthinkclawToolResult::success("ran"))
}
}
async fn run_exec_through_rx4(hook_ctx: ToolHookContext) -> (rx4::ToolResult, bool) {
let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
let tool: Arc<dyn UnthinkclawTool> = Arc::new(RecordingTool {
ran: Arc::clone(&ran),
});
let mut registry = rx4::ToolRegistry::new();
register_apollo_tools(&mut registry, &[tool], &hook_ctx);
let ctx = Arc::new(rx4::ToolContext::new("."));
let result = registry
.execute("exec", &ctx, r#"{"command":"rm -rf /"}"#)
.await
.expect("tool registered");
(result, ran.load(std::sync::atomic::Ordering::SeqCst))
}
#[tokio::test]
async fn rx4_bridge_enforces_blocking_hooks() {
let hook: Arc<dyn ToolHook> = Arc::new(crate::agent::hooks::PermissionHook::new(
vec!["exec".to_string()],
vec![],
));
let (result, ran) = run_exec_through_rx4(ToolHookContext::new(vec![hook], None)).await;
assert!(result.is_error, "blocked tool must report an error");
assert!(
result.content.contains("Blocked by policy"),
"unexpected content: {}",
result.content
);
assert!(!ran, "a blocked tool must not execute under rx4");
}
#[tokio::test]
async fn rx4_bridge_allows_unblocked_tools() {
let (result, ran) = run_exec_through_rx4(ToolHookContext::default()).await;
assert!(!result.is_error);
assert_eq!(result.content, "ran");
assert!(ran);
}
#[tokio::test]
async fn rx4_bridge_enforces_plugin_pre_tool_block() {
let mut registry = PluginRegistry::new();
registry.register_pre_tool_hook(Arc::new(BlockingPluginHook));
let ctx = ToolHookContext::new(
Vec::new(),
Some(Arc::new(tokio::sync::RwLock::new(registry))),
);
let (result, ran) = run_exec_through_rx4(ctx).await;
assert!(result.is_error);
assert!(
result.content.contains("Blocked by plugin"),
"unexpected content: {}",
result.content
);
assert!(!ran);
}
struct BlockingPluginHook;
#[async_trait::async_trait]
impl crate::plugin::PreToolHook for BlockingPluginHook {
fn name(&self) -> &str {
"blocking-test-hook"
}
async fn before_tool_call(&self, _name: &str, _arguments: &str) -> HookDecision {
HookDecision::Block("plugin says no".to_string())
}
}
struct RecordingLifecycleHook {
seen: Arc<std::sync::Mutex<Vec<String>>>,
}
#[async_trait::async_trait]
impl crate::plugin::LifecycleHook for RecordingLifecycleHook {
fn name(&self) -> &str {
"recording-lifecycle"
}
async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
let label = match event {
LifecycleEvent::BeforeToolCall(name, _) => format!("before:{name}"),
LifecycleEvent::AfterToolCall(name, _, _) => format!("after:{name}"),
other => format!("other:{other:?}"),
};
self.seen.lock().unwrap().push(label);
Ok(())
}
}
fn stream_labels(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AgentStreamEvent>,
) -> Vec<String> {
let mut labels = Vec::new();
while let Ok(event) = rx.try_recv() {
labels.push(match event {
AgentStreamEvent::ToolStart { name, .. } => format!("tool_start:{name}"),
AgentStreamEvent::ToolEnd { name, ok, .. } => format!("tool_end:{name}:{ok}"),
other => format!("other:{other:?}"),
});
}
labels
}
fn recording_context() -> (
ToolHookContext,
Arc<std::sync::Mutex<Vec<String>>>,
tokio::sync::mpsc::UnboundedReceiver<AgentStreamEvent>,
) {
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
let mut manager = HookManager::new();
manager.register_lifecycle(Arc::new(RecordingLifecycleHook {
seen: Arc::clone(&seen),
}));
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let ctx = ToolHookContext::default()
.with_hook_manager(Arc::new(manager))
.with_stream(Some(tx));
(ctx, seen, rx)
}
#[tokio::test]
async fn both_engines_emit_the_same_hooks_and_events() {
let args = r#"{"command":"ls"}"#;
let (ctx, rx4_seen, mut rx4_stream) = recording_context();
let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
let tool: Arc<dyn UnthinkclawTool> = Arc::new(RecordingTool {
ran: Arc::clone(&ran),
});
let mut registry = rx4::ToolRegistry::new();
register_apollo_tools(&mut registry, &[Arc::clone(&tool)], &ctx);
let tool_ctx = Arc::new(rx4::ToolContext::new("."));
registry
.execute("exec", &tool_ctx, args)
.await
.expect("tool registered");
let rx4_events = rx4_seen.lock().unwrap().clone();
let rx4_stream_events = stream_labels(&mut rx4_stream);
let (ctx, legacy_seen, mut legacy_stream) = recording_context();
execute_tool_with_hooks(&ctx, "exec", args, Some(&tool)).await;
let legacy_events = legacy_seen.lock().unwrap().clone();
let legacy_stream_events = stream_labels(&mut legacy_stream);
assert_eq!(
rx4_events, legacy_events,
"the paths disagree on lifecycle hooks"
);
assert_eq!(
rx4_stream_events, legacy_stream_events,
"the paths disagree on stream events"
);
assert_eq!(legacy_events, vec!["before:exec", "after:exec"]);
assert_eq!(
legacy_stream_events,
vec!["tool_start:exec", "tool_end:exec:true"]
);
}
#[tokio::test]
async fn a_blocked_tool_still_reports_start_and_end() {
let hook: Arc<dyn ToolHook> = Arc::new(crate::agent::hooks::PermissionHook::new(
vec!["exec".to_string()],
vec![],
));
let (ctx, seen, mut stream) = recording_context();
let ctx = ToolHookContext::new(vec![hook], None)
.with_hook_manager(Arc::new({
let mut manager = HookManager::new();
manager.register_lifecycle(Arc::new(RecordingLifecycleHook {
seen: Arc::clone(&seen),
}));
manager
}))
.with_stream(ctx.stream.clone());
let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
let tool: Arc<dyn UnthinkclawTool> = Arc::new(RecordingTool {
ran: Arc::clone(&ran),
});
let result = execute_tool_with_hooks(&ctx, "exec", "{}", Some(&tool)).await;
assert!(result.is_error);
assert!(!ran.load(std::sync::atomic::Ordering::SeqCst));
assert_eq!(
stream_labels(&mut stream),
vec!["tool_start:exec", "tool_end:exec:false"],
"a blocked call must still open and close its progress line"
);
assert_eq!(
seen.lock().unwrap().clone(),
vec!["before:exec", "after:exec"]
);
}
}