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