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