Skip to main content

atman_runtime/tools/
memory_stubs.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use tokio::sync::RwLock;
5
6use crate::error::RuntimeError;
7use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
8use crate::value::Value;
9
10use crate::migration::MigratedRule;
11
12#[derive(Default, Clone)]
13pub struct RuleFetch {
14    entries: Arc<RwLock<HashMap<String, String>>>,
15    migrated: Arc<RwLock<Vec<MigratedRule>>>,
16}
17
18impl RuleFetch {
19    pub fn new() -> Self {
20        Self::default()
21    }
22
23    pub async fn insert(&self, name: impl Into<String>, content: impl Into<String>) {
24        self.entries
25            .write()
26            .await
27            .insert(name.into(), content.into());
28    }
29
30    pub async fn set_migrated(&self, rules: Vec<MigratedRule>) {
31        *self.migrated.write().await = rules;
32    }
33
34    pub async fn migrated_count(&self) -> usize {
35        self.migrated.read().await.len()
36    }
37}
38
39impl Tool for RuleFetch {
40    fn name(&self) -> &str {
41        "rule.fetch"
42    }
43
44    fn tier(&self) -> Tier {
45        Tier::Zero
46    }
47
48    fn description(&self) -> Option<&str> {
49        Some(
50            "Load a skill/rule's full content by name, or search the rule index by keyword. \
51             Sources: CLAUDE.md, AGENTS.md, project and user skills, .cursorrules, \
52             .kiro/steering/*.md, and aider conventions.",
53        )
54    }
55
56    fn input_schema(&self) -> serde_json::Value {
57        serde_json::json!({
58            "type": "object",
59            "properties": {
60                "name": {
61                    "type": "string",
62                    "description": "Exact rule name to load full content (e.g. 'skill:code-review::references/rules.md')."
63                },
64                "query": {
65                    "type": "string",
66                    "description": "Keyword to search across rule names and descriptions. Returns a list of {name, description, scope, source, source_path}. Omit both name and query to list the full index."
67                }
68            }
69        })
70    }
71
72    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
73        Box::pin(async move {
74            // Search mode: query keyword → list of matching rules (index).
75            if let Some(Value::Str(query)) = args.named("query") {
76                let migrated = self.migrated.read().await;
77                let lower = query.to_lowercase();
78                let results: Vec<Value> = migrated
79                    .iter()
80                    .filter(|r| {
81                        r.name.to_lowercase().contains(&lower)
82                            || r.description
83                                .as_deref()
84                                .map(|d| d.to_lowercase().contains(&lower))
85                                .unwrap_or(false)
86                    })
87                    .map(rule_to_index_value)
88                    .collect();
89                return Ok(Value::List(results));
90            }
91
92            // No args → return the full compact index (name + description + scope + source).
93            if args.named("name").is_none() && args.positional(0).is_err() {
94                let migrated = self.migrated.read().await;
95                let index: Vec<Value> = migrated.iter().map(rule_to_index_value).collect();
96                return Ok(Value::List(index));
97            }
98
99            let name = extract_string(&args, "name", 0)?;
100            let entries = self.entries.read().await;
101            if let Some(content) = entries.get(&name) {
102                return Ok(Value::Str(content.clone()));
103            }
104            drop(entries);
105            let migrated = self.migrated.read().await;
106            if let Some(rule) = crate::migration::resolve_by_name(&migrated, &name) {
107                return Ok(Value::Str(rule.content.clone()));
108            }
109            Ok(Value::Str(String::new()))
110        })
111    }
112}
113
114/// Serialize a migrated rule into a compact index entry.
115fn rule_to_index_value(rule: &MigratedRule) -> Value {
116    use crate::value::Value as V;
117    V::Struct(vec![
118        ("name".into(), V::Str(rule.name.clone())),
119        (
120            "description".into(),
121            V::Str(rule.description.clone().unwrap_or_default()),
122        ),
123        ("scope".into(), V::Str(rule.scope.as_str().to_string())),
124        ("source".into(), V::Str(rule.source_tool.clone())),
125        (
126            "source_path".into(),
127            V::Str(rule.source_path.display().to_string()),
128        ),
129    ])
130}
131
132fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
133    let value = match args.named(name) {
134        Some(v) => v,
135        None => args.positional(pos)?,
136    };
137    match value {
138        Value::Str(s) => Ok(s.clone()),
139        other => Err(RuntimeError::TypeMismatch {
140            expected: "string".into(),
141            actual: other.kind_name().into(),
142        }),
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[tokio::test]
151    async fn rule_fetch_returns_stored_content() {
152        let tool = RuleFetch::new();
153        tool.insert("code-review", "review carefully").await;
154        let out = tool
155            .call(
156                ToolArgs {
157                    positional: vec![Value::Str("code-review".into())],
158                    named: vec![],
159                },
160                &ToolCtx::new(),
161            )
162            .await
163            .unwrap();
164        assert!(matches!(out, Value::Str(s) if s == "review carefully"));
165    }
166
167    #[tokio::test]
168    async fn rule_fetch_missing_returns_empty_string() {
169        let tool = RuleFetch::new();
170        let out = tool
171            .call(
172                ToolArgs {
173                    positional: vec![Value::Str("missing".into())],
174                    named: vec![],
175                },
176                &ToolCtx::new(),
177            )
178            .await
179            .unwrap();
180        assert!(matches!(out, Value::Str(s) if s.is_empty()));
181    }
182
183    fn migrated_rule(name: &str, description: &str) -> MigratedRule {
184        MigratedRule {
185            name: name.to_string(),
186            source_tool: "skill".to_string(),
187            source_path: "/tmp/x".into(),
188            scope: crate::migration::RuleScope::Global,
189            content: "content".to_string(),
190            description: Some(description.to_string()),
191        }
192    }
193
194    #[tokio::test]
195    async fn rule_fetch_query_searches_name_and_description() {
196        let tool = RuleFetch::new();
197        tool.set_migrated(vec![
198            migrated_rule("skill:code-review::a.md", "structured code review"),
199            migrated_rule("skill:deploy::b.md", "deployment checklist"),
200        ])
201        .await;
202
203        let out = tool
204            .call(
205                ToolArgs {
206                    positional: vec![],
207                    named: vec![("query".into(), Value::Str("review".into()))],
208                },
209                &ToolCtx::new(),
210            )
211            .await
212            .unwrap();
213        let Value::List(items) = out else {
214            panic!("expected list, got {out:?}")
215        };
216        assert_eq!(items.len(), 1, "only the review rule should match");
217        let Value::Struct(fields) = &items[0] else {
218            panic!("expected struct entry")
219        };
220        assert_eq!(fields[0].0, "name");
221        assert!(matches!(&fields[0].1, Value::Str(s) if s == "skill:code-review::a.md"));
222    }
223
224    #[tokio::test]
225    async fn rule_fetch_no_args_returns_full_index() {
226        let tool = RuleFetch::new();
227        tool.set_migrated(vec![
228            migrated_rule("a", "first"),
229            migrated_rule("b", "second"),
230        ])
231        .await;
232
233        let out = tool
234            .call(ToolArgs::default(), &ToolCtx::new())
235            .await
236            .unwrap();
237        let Value::List(items) = out else {
238            panic!("expected list, got {out:?}")
239        };
240        assert_eq!(items.len(), 2);
241    }
242}