Skip to main content

apollo/agent/
rotary_bridge.rs

1//! Rotary (rx4) bridge — adapts apollo's types to rx4's agent harness.
2//!
3//! This module provides:
4//! - `RotaryProviderAdapter`: wraps an apollo `Provider` as an `rx4::Provider`
5//!   so rx4's `Agent` loop can use apollo's existing provider backends.
6//! - `register_apollo_tools`: registers apollo's `Tool` trait objects
7//!   into rx4's `ToolRegistry` via boxed closures.
8//! - `chat_message_to_rx4` / `rx4_message_to_chat`: type translators between
9//!   apollo's `ChatMessage` and rx4's `Message`.
10//! - `RotaryAgentBridge`: wraps an `rx4::Agent`, wiring up provider, tools,
11//!   system prompt, and providing a `run_prompt` method that the outer
12//!   apollo shell (channels, swarm, cron, heartbeat) can call.
13//!
14//! The bridge delegates the core agent loop to rx4 while keeping apollo's
15//! unique features (channels, swarm, cron, heartbeat, autonomous mode,
16//! plugins, MCP) as the outer shell.
17
18use std::sync::Arc;
19
20use rx4::provider::{
21    Message, Provider as Rx4Provider, ProviderError as Rx4ProviderError, Role, StreamEvent,
22};
23
24use crate::agent::hooks::{run_post_hooks, run_pre_hooks, HookDecision, ToolHook};
25use crate::agent::stream::{emit, AgentStreamEvent, AgentStreamTx};
26use crate::plugin::{HookManager, LifecycleEvent, PluginRegistry};
27use crate::providers::{ChatMessage, ChatRequest, Provider as UnthinkclawProvider};
28use crate::tools::{Tool as UnthinkclawTool, ToolResult as UnthinkclawToolResult, ToolSpec};
29
30/// Everything a tool call must be wrapped in.
31///
32/// The same sequence runs around every tool: the `BeforeToolCall` lifecycle
33/// event, a `ToolStart` stream event, plugin then policy pre-checks,
34/// execution, the post hooks, the `AfterToolCall` lifecycle event, plugin
35/// notification, and a `ToolEnd` stream event. That sequence lives once, in
36/// `execute_tool_with_hooks`; this type carries the collaborators it needs.
37#[derive(Clone, Default)]
38pub struct ToolHookContext {
39    hooks: Vec<Arc<dyn ToolHook>>,
40    plugins: Option<Arc<tokio::sync::RwLock<PluginRegistry>>>,
41    hook_manager: Option<Arc<HookManager>>,
42    stream: Option<AgentStreamTx>,
43}
44
45impl ToolHookContext {
46    pub fn new(
47        hooks: Vec<Arc<dyn ToolHook>>,
48        plugins: Option<Arc<tokio::sync::RwLock<PluginRegistry>>>,
49    ) -> Self {
50        Self {
51            hooks,
52            plugins,
53            hook_manager: None,
54            stream: None,
55        }
56    }
57
58    /// Attach the lifecycle hook manager, so plugins observing tool calls see
59    /// them under either engine.
60    pub fn with_hook_manager(mut self, hook_manager: Arc<HookManager>) -> Self {
61        self.hook_manager = Some(hook_manager);
62        self
63    }
64
65    /// Attach the turn's stream sink, so a WS client sees tool progress under
66    /// either engine.
67    pub fn with_stream(mut self, stream: Option<AgentStreamTx>) -> Self {
68        self.stream = stream;
69        self
70    }
71
72    async fn emit_lifecycle(&self, event: LifecycleEvent) {
73        if let Some(manager) = &self.hook_manager {
74            manager.emit(&event).await;
75        }
76    }
77
78    /// Run the pre-tool checks. `Block` means the tool must not execute.
79    pub async fn check_pre_tool(&self, name: &str, arguments: &str) -> HookDecision {
80        if let Some(plugins) = &self.plugins {
81            let registry = plugins.read().await;
82            if let HookDecision::Block(reason) = registry.check_pre_tool(name, arguments).await {
83                return HookDecision::Block(format!("Blocked by plugin: {reason}"));
84            }
85        }
86        match run_pre_hooks(&self.hooks, name, arguments).await {
87            HookDecision::Block(reason) => {
88                HookDecision::Block(format!("Blocked by policy: {reason}"))
89            }
90            HookDecision::Allow => HookDecision::Allow,
91        }
92    }
93
94    /// Notify the post-tool hooks and plugins.
95    pub async fn notify_post_tool(
96        &self,
97        name: &str,
98        arguments: &str,
99        result: &UnthinkclawToolResult,
100    ) {
101        run_post_hooks(&self.hooks, name, arguments, result).await;
102        self.emit_lifecycle(LifecycleEvent::AfterToolCall(
103            name.to_string(),
104            arguments.to_string(),
105            result.clone(),
106        ))
107        .await;
108        if let Some(plugins) = &self.plugins {
109            let registry = plugins.read().await;
110            registry.notify_post_tool(name, arguments, result).await;
111        }
112    }
113}
114
115/// Run one tool call with every hook and event both engines owe it.
116///
117/// This is the single place the ordering exists. `tool` is `None` when the
118/// model named a tool that is not registered; the pre-checks still run, so a
119/// policy that blocks an unknown name is honoured before that is reported.
120pub async fn execute_tool_with_hooks(
121    ctx: &ToolHookContext,
122    name: &str,
123    arguments: &str,
124    tool: Option<&Arc<dyn UnthinkclawTool>>,
125) -> UnthinkclawToolResult {
126    ctx.emit_lifecycle(LifecycleEvent::BeforeToolCall(
127        name.to_string(),
128        arguments.to_string(),
129    ))
130    .await;
131    emit(
132        &ctx.stream,
133        AgentStreamEvent::ToolStart {
134            name: name.to_string(),
135            hint: crate::agent::loop_runner::extract_tool_hint(name, arguments),
136        },
137    );
138
139    let started = std::time::Instant::now();
140    let result = match ctx.check_pre_tool(name, arguments).await {
141        HookDecision::Block(reason) => {
142            tracing::info!("blocked '{}': {}", name, reason);
143            UnthinkclawToolResult::error(reason)
144        }
145        HookDecision::Allow => match tool {
146            Some(tool) => match tool.execute(arguments).await {
147                Ok(result) => result,
148                Err(e) => UnthinkclawToolResult::error(crate::redaction::redact_text(&format!(
149                    "Tool error: {e}"
150                ))),
151            },
152            None => UnthinkclawToolResult::error(format!("Unknown tool: {name}")),
153        },
154    };
155
156    ctx.notify_post_tool(name, arguments, &result).await;
157
158    emit(
159        &ctx.stream,
160        AgentStreamEvent::ToolEnd {
161            name: name.to_string(),
162            ok: !result.is_error,
163            elapsed_secs: started.elapsed().as_secs(),
164        },
165    );
166
167    result
168}
169
170// ── Message translation ──────────────────────────────────────────────────
171
172/// Convert an apollo `ChatMessage` to an rx4 `Message`.
173pub fn chat_message_to_rx4(msg: &ChatMessage) -> Message {
174    let role = match msg.role.as_str() {
175        "system" => Role::System,
176        "user" => Role::User,
177        "assistant" | "assistant_tool_use" => Role::Assistant,
178        "tool_result" => Role::Tool,
179        _ => Role::User,
180    };
181    Message {
182        role,
183        content: msg.content.clone(),
184        tool_call_id: msg.tool_use_id.clone(),
185        tool_calls: Vec::new(),
186    }
187}
188
189/// Convert an rx4 `Message` back to an apollo `ChatMessage`.
190pub fn rx4_message_to_chat(msg: &Message) -> ChatMessage {
191    let role = match msg.role {
192        Role::System => "system",
193        Role::User => "user",
194        Role::Assistant => "assistant",
195        Role::Tool => "tool_result",
196    };
197    ChatMessage {
198        role: role.to_string(),
199        content: msg.content.clone(),
200        tool_use_id: msg.tool_call_id.clone(),
201    }
202}
203
204// ── Provider adapter ─────────────────────────────────────────────────────
205
206/// Adapter that wraps an apollo `Provider` and implements rx4's `Provider`
207/// trait. This lets rx4's `Agent` loop use apollo's existing provider
208/// backends (Anthropic, OpenAI-compat, Ollama, Copilot) without modification.
209///
210/// rx4's `Provider` trait is streaming-based (`stream()`), while apollo's
211/// is request-response (`chat()`). This adapter bridges the gap by calling
212/// apollo's `chat()` and wrapping the result in a single-element stream.
213pub struct RotaryProviderAdapter {
214    inner: Arc<dyn UnthinkclawProvider>,
215    id: String,
216    name: String,
217}
218
219impl RotaryProviderAdapter {
220    pub fn new(provider: Arc<dyn UnthinkclawProvider>) -> Self {
221        let id = provider.name().to_string();
222        let name = format!("apollo-{}", provider.name());
223        Self {
224            inner: provider,
225            id,
226            name,
227        }
228    }
229}
230
231#[async_trait::async_trait]
232impl Rx4Provider for RotaryProviderAdapter {
233    fn id(&self) -> &str {
234        &self.id
235    }
236
237    fn name(&self) -> &str {
238        &self.name
239    }
240
241    async fn stream(
242        &self,
243        messages: &[Message],
244        system: &Option<String>,
245        model: &str,
246        tools: &[serde_json::Value],
247        _reasoning_effort: Option<&str>,
248    ) -> Result<rx4::provider::StreamResult, Rx4ProviderError> {
249        // Translate rx4 messages to apollo ChatMessages
250        let mut chat_messages: Vec<ChatMessage> = Vec::new();
251
252        // rx4 passes system prompt separately; apollo includes it in messages
253        if let Some(sys) = system {
254            chat_messages.push(ChatMessage::system(sys));
255        }
256
257        for msg in messages {
258            chat_messages.push(rx4_message_to_chat(msg));
259        }
260
261        // Convert rx4 tool definitions to apollo ToolSpecs
262        let tool_specs: Vec<ToolSpec> = tools
263            .iter()
264            .filter_map(|t| {
265                let name = t.get("name")?.as_str()?.to_string();
266                let description = t
267                    .get("description")
268                    .and_then(|d| d.as_str())
269                    .unwrap_or("")
270                    .to_string();
271                let parameters = t
272                    .get("parameters")
273                    .cloned()
274                    .unwrap_or(serde_json::Value::Null);
275                Some(ToolSpec {
276                    name,
277                    description,
278                    parameters,
279                })
280            })
281            .collect();
282
283        let tool_refs: &[ToolSpec] = if tool_specs.is_empty() {
284            &[]
285        } else {
286            // Safety: tool_specs lives for the duration of this call
287            // This is a workaround for the lifetime constraint in ChatRequest
288            &tool_specs
289        };
290
291        let request = ChatRequest {
292            messages: &chat_messages,
293            tools: if tool_refs.is_empty() {
294                None
295            } else {
296                Some(tool_refs)
297            },
298            model,
299            temperature: 0.7,
300            max_tokens: Some(8192),
301        };
302
303        let response = self
304            .inner
305            .chat(&request)
306            .await
307            .map_err(|e| Rx4ProviderError::Api(e.to_string()))?;
308
309        // Build a stream that emits the response as events
310        let text = response.text.unwrap_or_default();
311        let tool_calls = response.tool_calls;
312
313        // Create a single-shot stream
314        let events: Vec<Result<StreamEvent, Rx4ProviderError>> = {
315            let mut evs = Vec::new();
316            if !text.is_empty() {
317                evs.push(Ok(StreamEvent::Delta(text)));
318            }
319            for tc in tool_calls {
320                evs.push(Ok(StreamEvent::ToolCall(rx4::ToolCall {
321                    id: tc.id,
322                    name: tc.name,
323                    arguments: tc.arguments,
324                })));
325            }
326            evs.push(Ok(StreamEvent::Done));
327            evs
328        };
329
330        // Return a stream that yields the pre-computed events
331        use futures_util::stream;
332        Ok(Box::new(Box::pin(stream::iter(events))))
333    }
334}
335
336// ── Tool registration ────────────────────────────────────────────────────
337
338/// Register apollo's `Tool` trait objects into rx4's `ToolRegistry`.
339///
340/// Each apollo tool is wrapped in a boxed closure that captures the
341/// `Arc<dyn Tool>` and calls its `execute()` method. The closure is registered
342/// via `ToolDefinition::new_boxed()`, which uses `ToolExecutor::Boxed`.
343///
344/// Tool effects are classified based on the tool name using rx4's
345/// `classify_tool()` guardrail function — idempotent tools get `ToolEffect::Read`,
346/// mutating tools get `ToolEffect::Write`.
347pub fn register_apollo_tools(
348    registry: &mut rx4::ToolRegistry,
349    tools: &[Arc<dyn UnthinkclawTool>],
350    hook_ctx: &ToolHookContext,
351) {
352    use rx4::guardrails::classify_tool;
353    use rx4::{ToolDefinition, ToolEffect, ToolExecuteBox};
354
355    for tool in tools {
356        let spec = tool.spec();
357        let name = spec.name.clone();
358        let description = spec.description.clone();
359        let parameters_json = serde_json::to_string(&spec.parameters).unwrap_or_default();
360
361        let tool_clone = Arc::clone(tool);
362        let hook_ctx = hook_ctx.clone();
363        let tool_name = name.clone();
364        let execute: ToolExecuteBox = Box::new(move |_ctx, args| {
365            let tool = Arc::clone(&tool_clone);
366            let hook_ctx = hook_ctx.clone();
367            let tool_name = tool_name.clone();
368            Box::pin(async move {
369                let result =
370                    execute_tool_with_hooks(&hook_ctx, &tool_name, &args, Some(&tool)).await;
371
372                rx4::ToolResult {
373                    id: String::new(),
374                    content: result.output,
375                    is_error: result.is_error,
376                }
377            })
378        });
379
380        let effect = match classify_tool(&name) {
381            rx4::guardrails::ToolClass::Idempotent => ToolEffect::Read,
382            rx4::guardrails::ToolClass::Mutating => ToolEffect::Write,
383        };
384
385        registry.register(
386            ToolDefinition::new_boxed(name, description, parameters_json, execute)
387                .with_effect(effect),
388        );
389    }
390}
391
392// ── Agent bridge ─────────────────────────────────────────────────────────
393
394/// Configuration for building a `RotaryAgentBridge`.
395pub struct RotaryBridgeConfig {
396    pub provider: Arc<dyn UnthinkclawProvider>,
397    pub tools: Vec<Arc<dyn UnthinkclawTool>>,
398    pub system_prompt: String,
399    pub model: String,
400    pub workspace: std::path::PathBuf,
401    pub max_tool_iterations: usize,
402    /// rx4 auto-compaction threshold. `0` leaves compaction off; a non-zero
403    /// value is forwarded to `Agent::auto_compact_after`.
404    pub auto_compact_after: usize,
405    /// Pre/post tool hooks, so rx4 enforces the same permissions as the
406    /// legacy loop.
407    pub hook_ctx: ToolHookContext,
408}
409
410/// Bridge that wraps an `rx4::Agent` and provides a simplified interface for
411/// apollo's outer shell to use.
412///
413/// The bridge handles:
414/// - Creating and configuring the rx4::Agent (provider, tools, system prompt)
415/// - Translating messages between apollo and rx4 types
416/// - Running prompts through rx4's agent loop
417///
418/// Unthinkclaw's unique features (channels, swarm, cron, heartbeat, autonomous
419/// mode, plugins) remain in the outer shell and call `run_prompt()` on this
420/// bridge to execute agent turns.
421pub struct RotaryAgentBridge {
422    agent: rx4::Agent,
423    hook_ctx: ToolHookContext,
424    /// Conversation messages maintained in rx4 format (per-session)
425    messages: Vec<Message>,
426}
427
428impl RotaryAgentBridge {
429    /// Build a new bridge from the given configuration.
430    pub fn new(config: RotaryBridgeConfig) -> Self {
431        let rx4_provider = Arc::new(RotaryProviderAdapter::new(config.provider));
432
433        let mut agent = rx4::Agent::new();
434        agent.set_model(&config.model);
435        agent.set_system_prompt(&config.system_prompt);
436        agent.set_provider(rx4_provider);
437        agent.set_workspace_root(&config.workspace);
438        agent.max_tool_iterations = config.max_tool_iterations;
439        // rx4 leaves `auto_compact_after` at `0` by default, which disables
440        // compaction. Forward the configured threshold so a non-zero value
441        // turns rx4's auto-compact on.
442        agent.auto_compact_after = config.auto_compact_after;
443
444        // apollo, not rx4, is the authorization authority here.
445        //
446        // `rx4::Policy` defaults to `workspace_write()`, which asks for
447        // approval before running a tool it does not recognise. With no
448        // approver attached that resolves to a denial, so leaving the default
449        // in place means *no apollo tool can ever run under this engine* — the
450        // turn completes with every call reporting "approval required".
451        //
452        // Authorization instead happens one layer in, inside the closure
453        // `register_apollo_tools` installs: `execute_tool_with_hooks` runs
454        // apollo's `PermissionHook` and the plugin pre-tool hooks, which are
455        // driven by apollo's own permission profile and mode. Handing rx4
456        // `full_access` makes it defer to that single gate rather than
457        // second-guessing it with a policy apollo never configured.
458        agent.set_policy(rx4::Policy::full_access());
459
460        // Register apollo's tools into rx4's tool registry
461        let mut tool_registry = rx4::ToolRegistry::new();
462        register_apollo_tools(&mut tool_registry, &config.tools, &config.hook_ctx);
463        agent.tools = Arc::new(tool_registry);
464
465        Self {
466            agent,
467            hook_ctx: config.hook_ctx,
468            messages: Vec::new(),
469        }
470    }
471
472    /// Get a reference to the inner rx4::Agent (for advanced configuration).
473    pub fn agent(&self) -> &rx4::Agent {
474        &self.agent
475    }
476
477    /// Get a mutable reference to the inner rx4::Agent.
478    pub fn agent_mut(&mut self) -> &mut rx4::Agent {
479        &mut self.agent
480    }
481
482    /// Clear the conversation history.
483    pub fn clear_messages(&mut self) {
484        self.messages.clear();
485        self.agent.clear_messages();
486    }
487
488    /// Get the number of messages in the conversation.
489    pub fn message_count(&self) -> usize {
490        self.messages.len()
491    }
492
493    /// Set the model for the agent.
494    pub fn set_model(&mut self, model: &str) {
495        self.agent.set_model(model);
496    }
497
498    /// Set the system prompt.
499    pub fn set_system_prompt(&mut self, prompt: &str) {
500        self.agent.set_system_prompt(prompt);
501    }
502
503    /// Set the workspace root.
504    pub fn set_workspace_root(&mut self, path: &std::path::Path) {
505        self.agent.set_workspace_root(path);
506    }
507
508    /// Set the scope (e.g., Coding, Research, Ask).
509    pub fn set_scope(&mut self, scope: rx4::Scope) {
510        self.agent.set_scope(scope);
511    }
512
513    /// Add a subscriber to receive agent events (tool calls, deltas, etc.).
514    pub fn subscribe(&mut self, callback: impl Fn(&rx4::Event) + Send + Sync + 'static) {
515        self.agent.subscribe(callback);
516    }
517
518    /// Run a single user prompt through the rx4 agent loop.
519    ///
520    /// This delegates the core agent loop (LLM calls, tool execution, turn
521    /// cycling) to rx4::Agent. The caller (apollo's channel/swarm/cron
522    /// shell) is responsible for:
523    /// - Receiving the user message from a channel
524    /// - Calling this method with the prompt text
525    /// - Sending the final response back through the channel
526    ///
527    /// Returns the final assistant response text.
528    pub async fn run_prompt(&mut self, prompt: &str) -> anyhow::Result<String> {
529        // Track the last assistant message for the return value
530        let last_response = Arc::new(parking_lot::RwLock::new(String::new()));
531        let last_response_clone = Arc::clone(&last_response);
532
533        self.agent.subscribe(move |event| {
534            if let rx4::Event::MessageEnd {
535                content,
536                role: Role::Assistant,
537            } = event
538            {
539                *last_response_clone.write() = content.clone();
540            }
541        });
542
543        self.agent.prompt(prompt).await?;
544
545        let response = last_response.read().clone();
546        Ok(response)
547    }
548
549    /// Run a prompt with pre-loaded conversation history.
550    ///
551    /// The history is loaded into rx4's message buffer before running the
552    /// prompt. This is used when apollo's memory backend provides
553    /// conversation history for a chat session.
554    pub async fn run_prompt_with_history(
555        &mut self,
556        prompt: &str,
557        history: &[ChatMessage],
558    ) -> anyhow::Result<String> {
559        // Load history into rx4's message buffer
560        self.agent.clear_messages();
561        for msg in history {
562            let rx4_msg = chat_message_to_rx4(msg);
563            // rx4's messages are stored internally; we push them via the
564            // messages RwLock
565            self.agent.messages.write().push(rx4_msg);
566        }
567
568        self.run_prompt(prompt).await
569    }
570
571    /// Register additional tools at runtime.
572    pub fn register_tools(&mut self, tools: &[Arc<dyn UnthinkclawTool>]) {
573        if let Some(registry) = Arc::get_mut(&mut self.agent.tools) {
574            register_apollo_tools(registry, tools, &self.hook_ctx);
575        } else {
576            tracing::warn!("cannot register rx4 tools while the registry is shared");
577        }
578    }
579
580    /// Get the list of registered tool names.
581    pub fn list_tools(&self) -> Vec<String> {
582        self.agent
583            .tools
584            .definitions()
585            .iter()
586            .filter_map(|d| {
587                d.get("name")
588                    .and_then(|n| n.as_str())
589                    .map(|s| s.to_string())
590            })
591            .collect()
592    }
593
594    /// Compact the conversation context (delegates to rx4's compact).
595    pub fn compact(&mut self, reason: &str) {
596        self.agent.compact(reason);
597    }
598
599    /// Give rx4 a shared handle on the message buffer.
600    ///
601    /// rx4 0.5.0 keeps `Agent::messages` behind an `Arc`, so a host can append
602    /// to the conversation while `prompt()` is still running and the next tool
603    /// iteration will see it. This is what apollo's steering queue needs: a
604    /// message that arrives mid-turn is pushed here rather than queued until
605    /// the turn ends.
606    pub fn messages_handle(&self) -> Arc<parking_lot::RwLock<Vec<Message>>> {
607        self.agent.messages_handle()
608    }
609
610    /// Load rx4's `SkillEngine` over apollo's skill directories and hand it to
611    /// the agent, which runs its background skill reviewer after each prompt.
612    ///
613    /// This is additive to apollo's own `skills` module: rx4's engine does not
614    /// perform apollo's template-variable substitution or inline shell
615    /// expansion, so it supplements rather than replaces `skills::match_skill`.
616    pub fn enable_skill_engine(&mut self, workspace: &std::path::Path) {
617        let mut engine = build_rx4_skill_engine(workspace);
618        if let Err(error) = engine.load() {
619            tracing::warn!("rx4 skill engine load failed, leaving it unset: {error}");
620            return;
621        }
622        self.agent.set_skill_engine(engine);
623    }
624
625    /// Attach an rx4 `GraphMemory` rooted at the workspace.
626    ///
627    /// rx4 extracts concepts, decisions and patterns from the conversation
628    /// after each prompt and adds them to the graph. `auto_dream` additionally
629    /// runs one consolidation pass per prompt.
630    pub fn enable_graph_memory(&mut self, workspace: &std::path::Path, auto_dream: bool) {
631        self.agent
632            .set_graph_memory(rx4::GraphMemory::from_workspace(workspace));
633        self.agent.enable_auto_dream(auto_dream);
634    }
635}
636
637// ── Skill bridge ─────────────────────────────────────────────────────────
638
639/// Build an `rx4::SkillEngine` configured with apollo's skill directories.
640///
641/// Unthinkclaw discovers skills from 3 directories:
642/// 1. `~/.npm-global/lib/node_modules/openclaw/skills` (legacy)
643/// 2. `~/.openclaw/workspace/skills` (shared workspace skills)
644/// 3. `{workspace}/.apollo/skills` (project-local managed skills)
645///
646/// This maps to rx4's `SkillEngine` with the primary dir set to the managed
647/// skills directory and the other two as `extra_dirs`.
648///
649/// After calling this, use `engine.load()` to populate skills from disk,
650/// then `engine.search()` for keyword matching (replaces apollo's
651/// `match_skill()`).
652///
653/// Note: apollo's template variable substitution and inline shell
654/// preprocessing (`preprocess_skill_content`) are not part of rx4's
655/// SkillEngine and remain in apollo's `skills` module. Use
656/// `skills::preprocess_skill_content()` on the matched skill's instructions
657/// before injecting into the system prompt.
658pub fn build_rx4_skill_engine(workspace: &std::path::Path) -> rx4::SkillEngine {
659    let home = dirs::home_dir().unwrap_or_default();
660
661    // Primary dir: managed skills in the workspace
662    let managed_dir = workspace.join(".apollo/skills");
663
664    let mut engine = rx4::SkillEngine::new(managed_dir);
665
666    // Extra dirs: legacy openclaw skills and shared workspace skills
667    let openclaw_skills = home.join(".npm-global/lib/node_modules/openclaw/skills");
668    engine.add_extra_dir(openclaw_skills);
669
670    let shared_skills = home.join(".openclaw/workspace/skills");
671    engine.add_extra_dir(shared_skills);
672
673    engine
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679
680    #[test]
681    fn test_chat_message_to_rx4_system() {
682        let msg = ChatMessage::system("hello");
683        let rx4_msg = chat_message_to_rx4(&msg);
684        assert_eq!(rx4_msg.role, Role::System);
685        assert_eq!(rx4_msg.content, "hello");
686    }
687
688    #[test]
689    fn test_chat_message_to_rx4_user() {
690        let msg = ChatMessage::user("test");
691        let rx4_msg = chat_message_to_rx4(&msg);
692        assert_eq!(rx4_msg.role, Role::User);
693        assert_eq!(rx4_msg.content, "test");
694    }
695
696    #[test]
697    fn test_chat_message_to_rx4_tool_result() {
698        let msg = ChatMessage::tool_result("tc_123", "result text");
699        let rx4_msg = chat_message_to_rx4(&msg);
700        assert_eq!(rx4_msg.role, Role::Tool);
701        assert_eq!(rx4_msg.content, "result text");
702        assert_eq!(rx4_msg.tool_call_id.as_deref(), Some("tc_123"));
703    }
704
705    #[test]
706    fn test_rx4_message_to_chat() {
707        let msg = Message::assistant("hello back");
708        let chat_msg = rx4_message_to_chat(&msg);
709        assert_eq!(chat_msg.role, "assistant");
710        assert_eq!(chat_msg.content, "hello back");
711    }
712
713    #[test]
714    fn test_roundtrip_translation() {
715        let original = ChatMessage::user("roundtrip test");
716        let rx4_msg = chat_message_to_rx4(&original);
717        let back = rx4_message_to_chat(&rx4_msg);
718        assert_eq!(back.role, "user");
719        assert_eq!(back.content, "roundtrip test");
720    }
721
722    #[test]
723    fn test_build_rx4_skill_engine() {
724        // Just verify it doesn't panic with a temp dir
725        let tmp = tempfile::tempdir().unwrap();
726        let engine = build_rx4_skill_engine(tmp.path());
727        assert!(
728            engine.skills_dir().exists()
729                || engine.skills_dir() == tmp.path().join(".apollo/skills")
730        );
731    }
732
733    struct RecordingTool {
734        ran: Arc<std::sync::atomic::AtomicBool>,
735    }
736
737    #[async_trait::async_trait]
738    impl UnthinkclawTool for RecordingTool {
739        fn name(&self) -> &str {
740            "exec"
741        }
742
743        fn spec(&self) -> ToolSpec {
744            ToolSpec {
745                name: "exec".to_string(),
746                description: "test tool".to_string(),
747                parameters: serde_json::json!({"type": "object"}),
748            }
749        }
750
751        async fn execute(&self, _arguments: &str) -> anyhow::Result<UnthinkclawToolResult> {
752            self.ran.store(true, std::sync::atomic::Ordering::SeqCst);
753            Ok(UnthinkclawToolResult::success("ran"))
754        }
755    }
756
757    async fn run_exec_through_rx4(hook_ctx: ToolHookContext) -> (rx4::ToolResult, bool) {
758        let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
759        let tool: Arc<dyn UnthinkclawTool> = Arc::new(RecordingTool {
760            ran: Arc::clone(&ran),
761        });
762        let mut registry = rx4::ToolRegistry::new();
763        register_apollo_tools(&mut registry, &[tool], &hook_ctx);
764
765        let ctx = Arc::new(rx4::ToolContext::new("."));
766        let result = registry
767            .execute("exec", &ctx, r#"{"command":"rm -rf /"}"#)
768            .await
769            .expect("tool registered");
770        (result, ran.load(std::sync::atomic::Ordering::SeqCst))
771    }
772
773    #[tokio::test]
774    async fn rx4_bridge_enforces_blocking_hooks() {
775        let hook: Arc<dyn ToolHook> = Arc::new(crate::agent::hooks::PermissionHook::new(
776            vec!["exec".to_string()],
777            vec![],
778        ));
779        let (result, ran) = run_exec_through_rx4(ToolHookContext::new(vec![hook], None)).await;
780        assert!(result.is_error, "blocked tool must report an error");
781        assert!(
782            result.content.contains("Blocked by policy"),
783            "unexpected content: {}",
784            result.content
785        );
786        assert!(!ran, "a blocked tool must not execute under rx4");
787    }
788
789    #[tokio::test]
790    async fn rx4_bridge_allows_unblocked_tools() {
791        let (result, ran) = run_exec_through_rx4(ToolHookContext::default()).await;
792        assert!(!result.is_error);
793        assert_eq!(result.content, "ran");
794        assert!(ran);
795    }
796
797    #[tokio::test]
798    async fn rx4_bridge_enforces_plugin_pre_tool_block() {
799        let mut registry = PluginRegistry::new();
800        registry.register_pre_tool_hook(Arc::new(BlockingPluginHook));
801        let ctx = ToolHookContext::new(
802            Vec::new(),
803            Some(Arc::new(tokio::sync::RwLock::new(registry))),
804        );
805        let (result, ran) = run_exec_through_rx4(ctx).await;
806        assert!(result.is_error);
807        assert!(
808            result.content.contains("Blocked by plugin"),
809            "unexpected content: {}",
810            result.content
811        );
812        assert!(!ran);
813    }
814
815    struct BlockingPluginHook;
816
817    #[async_trait::async_trait]
818    impl crate::plugin::PreToolHook for BlockingPluginHook {
819        fn name(&self) -> &str {
820            "blocking-test-hook"
821        }
822
823        async fn before_tool_call(&self, _name: &str, _arguments: &str) -> HookDecision {
824            HookDecision::Block("plugin says no".to_string())
825        }
826    }
827
828    /// Records the lifecycle events a plugin would see.
829    struct RecordingLifecycleHook {
830        seen: Arc<std::sync::Mutex<Vec<String>>>,
831    }
832
833    #[async_trait::async_trait]
834    impl crate::plugin::LifecycleHook for RecordingLifecycleHook {
835        fn name(&self) -> &str {
836            "recording-lifecycle"
837        }
838
839        async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
840            let label = match event {
841                LifecycleEvent::BeforeToolCall(name, _) => format!("before:{name}"),
842                LifecycleEvent::AfterToolCall(name, _, _) => format!("after:{name}"),
843                other => format!("other:{other:?}"),
844            };
845            self.seen.lock().unwrap().push(label);
846            Ok(())
847        }
848    }
849
850    fn stream_labels(
851        rx: &mut tokio::sync::mpsc::UnboundedReceiver<AgentStreamEvent>,
852    ) -> Vec<String> {
853        let mut labels = Vec::new();
854        while let Ok(event) = rx.try_recv() {
855            labels.push(match event {
856                AgentStreamEvent::ToolStart { name, .. } => format!("tool_start:{name}"),
857                AgentStreamEvent::ToolEnd { name, ok, .. } => format!("tool_end:{name}:{ok}"),
858                other => format!("other:{other:?}"),
859            });
860        }
861        labels
862    }
863
864    /// Build a context that records everything a plugin or WS client sees.
865    fn recording_context() -> (
866        ToolHookContext,
867        Arc<std::sync::Mutex<Vec<String>>>,
868        tokio::sync::mpsc::UnboundedReceiver<AgentStreamEvent>,
869    ) {
870        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
871        let mut manager = HookManager::new();
872        manager.register_lifecycle(Arc::new(RecordingLifecycleHook {
873            seen: Arc::clone(&seen),
874        }));
875        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
876        let ctx = ToolHookContext::default()
877            .with_hook_manager(Arc::new(manager))
878            .with_stream(Some(tx));
879        (ctx, seen, rx)
880    }
881
882    /// The rx4 registry closure and a direct call must produce the same hooks
883    /// and stream events for a tool call. Both reach the tool through
884    /// `execute_tool_with_hooks`; this fails if either side stops doing so,
885    /// which is how rx4 previously lost `BeforeToolCall` and the
886    /// `ToolStart`/`ToolEnd` progress events.
887    #[tokio::test]
888    async fn both_engines_emit_the_same_hooks_and_events() {
889        let args = r#"{"command":"ls"}"#;
890
891        // rx4: the tool runs inside the registry closure.
892        let (ctx, rx4_seen, mut rx4_stream) = recording_context();
893        let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
894        let tool: Arc<dyn UnthinkclawTool> = Arc::new(RecordingTool {
895            ran: Arc::clone(&ran),
896        });
897        let mut registry = rx4::ToolRegistry::new();
898        register_apollo_tools(&mut registry, &[Arc::clone(&tool)], &ctx);
899        let tool_ctx = Arc::new(rx4::ToolContext::new("."));
900        registry
901            .execute("exec", &tool_ctx, args)
902            .await
903            .expect("tool registered");
904        let rx4_events = rx4_seen.lock().unwrap().clone();
905        let rx4_stream_events = stream_labels(&mut rx4_stream);
906
907        // Direct: the shared path called directly.
908        let (ctx, legacy_seen, mut legacy_stream) = recording_context();
909        execute_tool_with_hooks(&ctx, "exec", args, Some(&tool)).await;
910        let legacy_events = legacy_seen.lock().unwrap().clone();
911        let legacy_stream_events = stream_labels(&mut legacy_stream);
912
913        assert_eq!(
914            rx4_events, legacy_events,
915            "the paths disagree on lifecycle hooks"
916        );
917        assert_eq!(
918            rx4_stream_events, legacy_stream_events,
919            "the paths disagree on stream events"
920        );
921        assert_eq!(legacy_events, vec!["before:exec", "after:exec"]);
922        assert_eq!(
923            legacy_stream_events,
924            vec!["tool_start:exec", "tool_end:exec:true"]
925        );
926    }
927
928    #[tokio::test]
929    async fn a_blocked_tool_still_reports_start_and_end() {
930        let hook: Arc<dyn ToolHook> = Arc::new(crate::agent::hooks::PermissionHook::new(
931            vec!["exec".to_string()],
932            vec![],
933        ));
934        let (ctx, seen, mut stream) = recording_context();
935        let ctx = ToolHookContext::new(vec![hook], None)
936            .with_hook_manager(Arc::new({
937                let mut manager = HookManager::new();
938                manager.register_lifecycle(Arc::new(RecordingLifecycleHook {
939                    seen: Arc::clone(&seen),
940                }));
941                manager
942            }))
943            .with_stream(ctx.stream.clone());
944        let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
945        let tool: Arc<dyn UnthinkclawTool> = Arc::new(RecordingTool {
946            ran: Arc::clone(&ran),
947        });
948        let result = execute_tool_with_hooks(&ctx, "exec", "{}", Some(&tool)).await;
949        assert!(result.is_error);
950        assert!(!ran.load(std::sync::atomic::Ordering::SeqCst));
951        assert_eq!(
952            stream_labels(&mut stream),
953            vec!["tool_start:exec", "tool_end:exec:false"],
954            "a blocked call must still open and close its progress line"
955        );
956        assert_eq!(
957            seen.lock().unwrap().clone(),
958            vec!["before:exec", "after:exec"]
959        );
960    }
961}