Skip to main content

bamboo_server/server_tools/
skill_runtime.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use serde::Deserialize;
7use serde_json::json;
8use tokio::sync::RwLock;
9
10use bamboo_engine::access_control::{self, SkillAccessError, SkillSessionPort};
11use bamboo_engine::resource_helpers::{
12    display_relative_path, list_skill_resource_paths, normalize_relative_resource_path,
13    page_text_lines, truncate_text,
14};
15use bamboo_engine::runtime_metadata::LAST_RESOURCE_READ_SUMMARY_METADATA_KEY;
16use bamboo_engine::SkillManager;
17use bamboo_infrastructure::Config;
18
19use bamboo_agent_core::storage::Storage;
20use bamboo_agent_core::tools::{Tool, ToolError, ToolExecutionContext, ToolResult};
21use bamboo_agent_core::Session;
22use bamboo_infrastructure::LockedSessionStore;
23
24const MAX_RESOURCE_CONTENT_CHARS: usize = 50_000;
25
26#[derive(Clone)]
27struct SkillToolAccess {
28    skill_manager: Arc<SkillManager>,
29    config: Arc<RwLock<Config>>,
30    sessions: Arc<RwLock<HashMap<String, Session>>>,
31    storage: Arc<dyn Storage>,
32    persistence: Arc<LockedSessionStore>,
33}
34
35impl SkillToolAccess {
36    fn new(
37        skill_manager: Arc<SkillManager>,
38        config: Arc<RwLock<Config>>,
39        sessions: Arc<RwLock<HashMap<String, Session>>>,
40        storage: Arc<dyn Storage>,
41        persistence: Arc<LockedSessionStore>,
42    ) -> Self {
43        Self {
44            skill_manager,
45            config,
46            sessions,
47            storage,
48            persistence,
49        }
50    }
51
52    async fn session_for_context(&self, session_id: Option<&str>) -> Option<Session> {
53        let session_id = session_id?;
54
55        let in_memory = {
56            let sessions = self.sessions.read().await;
57            sessions.get(session_id).cloned()
58        };
59
60        match in_memory {
61            Some(session) => Some(session),
62            None => self.storage.load_session(session_id).await.ok().flatten(),
63        }
64    }
65
66    async fn skill_root(
67        &self,
68        skill_id: &str,
69        skill_mode: Option<&str>,
70    ) -> Result<PathBuf, ToolError> {
71        self.skill_manager
72            .store()
73            .get_skill_root_for_mode(skill_id, skill_mode)
74            .await
75            .map_err(|err| ToolError::Execution(format!("Failed to resolve skill root: {err}")))
76    }
77}
78
79#[async_trait]
80impl SkillSessionPort for SkillToolAccess {
81    async fn load_session_metadata(&self, session_id: &str) -> Option<HashMap<String, String>> {
82        self.session_for_context(Some(session_id))
83            .await
84            .map(|session| session.metadata.clone())
85    }
86
87    async fn save_metadata_updates(
88        &self,
89        session_id: &str,
90        updates: &[(String, Option<String>)],
91    ) -> Result<(), String> {
92        let mut session = {
93            let sessions = self.sessions.read().await;
94            sessions.get(session_id).cloned()
95        };
96
97        if session.is_none() {
98            session = self
99                .storage
100                .load_session(session_id)
101                .await
102                .map_err(|e| e.to_string())?;
103        }
104
105        let mut session = session.ok_or_else(|| format!("Session '{session_id}' not found"))?;
106
107        for (key, value) in updates {
108            if let Some(val) = value {
109                session.metadata.insert(key.clone(), val.clone());
110            } else {
111                session.metadata.remove(key);
112            }
113        }
114
115        self.persistence
116            .merge_save_runtime(&mut session)
117            .await
118            .map_err(|e| e.to_string())?;
119
120        let mut sessions = self.sessions.write().await;
121        sessions.insert(session_id.to_string(), session);
122
123        Ok(())
124    }
125
126    async fn disabled_skill_ids(&self) -> HashSet<String> {
127        let config = self.config.read().await;
128        config.disabled_skill_ids().into_iter().collect()
129    }
130}
131
132fn skill_access_error_to_tool_error(error: SkillAccessError) -> ToolError {
133    match error {
134        SkillAccessError::NotAllowed(msg)
135        | SkillAccessError::NotLoaded(msg)
136        | SkillAccessError::SessionRequired(msg)
137        | SkillAccessError::SessionNotFound(msg)
138        | SkillAccessError::PersistenceError(msg) => ToolError::Execution(msg),
139    }
140}
141
142#[derive(Debug, Deserialize)]
143struct LoadSkillArgs {
144    skill_id: String,
145}
146
147pub struct LoadSkillTool {
148    access: SkillToolAccess,
149}
150
151impl LoadSkillTool {
152    pub fn new(
153        skill_manager: Arc<SkillManager>,
154        config: Arc<RwLock<Config>>,
155        sessions: Arc<RwLock<HashMap<String, Session>>>,
156        storage: Arc<dyn Storage>,
157        persistence: Arc<LockedSessionStore>,
158    ) -> Self {
159        Self {
160            access: SkillToolAccess::new(skill_manager, config, sessions, storage, persistence),
161        }
162    }
163}
164
165#[async_trait]
166impl Tool for LoadSkillTool {
167    fn name(&self) -> &str {
168        "load_skill"
169    }
170
171    fn description(&self) -> &str {
172        "Load a skill's detailed SKILL.md instructions by skill_id."
173    }
174
175    fn parameters_schema(&self) -> serde_json::Value {
176        json!({
177            "type": "object",
178            "properties": {
179                "skill_id": {
180                    "type": "string",
181                    "description": "Skill ID from the advertised skill list (for example: skill-creator)."
182                }
183            },
184            "required": ["skill_id"]
185        })
186    }
187
188    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
189        self.execute_with_context(args, ToolExecutionContext::none("tool_call"))
190            .await
191    }
192
193    async fn execute_with_context(
194        &self,
195        args: serde_json::Value,
196        ctx: ToolExecutionContext<'_>,
197    ) -> Result<ToolResult, ToolError> {
198        let parsed: LoadSkillArgs = serde_json::from_value(args).map_err(|err| {
199            ToolError::InvalidArguments(format!("Invalid load_skill args: {err}"))
200        })?;
201        let skill_id = parsed.skill_id.trim();
202        if skill_id.is_empty() {
203            return Err(ToolError::InvalidArguments(
204                "skill_id must be a non-empty string".to_string(),
205            ));
206        }
207
208        access_control::ensure_skill_allowed(&self.access, skill_id, ctx.session_id)
209            .await
210            .map_err(skill_access_error_to_tool_error)?;
211        let skill_mode = access_control::selected_skill_mode(&self.access, ctx.session_id).await;
212
213        let skill = self
214            .access
215            .skill_manager
216            .store()
217            .get_skill_for_mode(skill_id, skill_mode.as_deref())
218            .await
219            .map_err(|err| {
220                ToolError::Execution(format!("Failed to load skill '{skill_id}': {err}"))
221            })?;
222        let skill_root = self
223            .access
224            .skill_root(skill_id, skill_mode.as_deref())
225            .await?;
226        let resources = list_skill_resource_paths(&skill_root).map_err(|err| {
227            ToolError::Execution(format!("Failed to list skill resources: {err}"))
228        })?;
229        let canonical_skill_root = tokio::fs::canonicalize(&skill_root)
230            .await
231            .unwrap_or(skill_root);
232        access_control::mark_skill_loaded(&self.access, skill_id, ctx.session_id)
233            .await
234            .map_err(skill_access_error_to_tool_error)?;
235
236        Ok(ToolResult {
237            success: true,
238            result: json!({
239                "skill_id": skill.id,
240                "name": skill.name,
241                "description": skill.description,
242                "license": skill.license,
243                "compatibility": skill.compatibility,
244                "allowed_tools": skill.tool_refs,
245                "instructions": skill.prompt,
246                "skill_base_dir": bamboo_infrastructure::paths::path_to_display_string(&canonical_skill_root),
247                "resource_files": resources
248            })
249            .to_string(),
250            display_preference: Some("Collapsible".to_string()),
251        })
252    }
253}
254
255#[derive(Debug, Deserialize)]
256struct ReadSkillResourceArgs {
257    skill_id: String,
258    resource_path: String,
259    #[serde(default)]
260    offset: Option<usize>,
261    #[serde(default)]
262    limit: Option<usize>,
263}
264
265pub struct ReadSkillResourceTool {
266    access: SkillToolAccess,
267}
268
269impl ReadSkillResourceTool {
270    pub fn new(
271        skill_manager: Arc<SkillManager>,
272        config: Arc<RwLock<Config>>,
273        sessions: Arc<RwLock<HashMap<String, Session>>>,
274        storage: Arc<dyn Storage>,
275        persistence: Arc<LockedSessionStore>,
276    ) -> Self {
277        Self {
278            access: SkillToolAccess::new(skill_manager, config, sessions, storage, persistence),
279        }
280    }
281}
282
283#[async_trait]
284impl Tool for ReadSkillResourceTool {
285    fn name(&self) -> &str {
286        "read_skill_resource"
287    }
288
289    fn description(&self) -> &str {
290        "Read a resource file under a skill directory by relative resource_path."
291    }
292
293    fn parameters_schema(&self) -> serde_json::Value {
294        json!({
295            "type": "object",
296            "properties": {
297                "skill_id": {
298                    "type": "string",
299                    "description": "Skill ID that owns the resource."
300                },
301                "resource_path": {
302                    "type": "string",
303                    "description": "Relative path inside the skill folder (for example: references/policies.md)."
304                },
305                "offset": {
306                    "type": "number",
307                    "description": "Optional 0-based line offset for paged text reads."
308                },
309                "limit": {
310                    "type": "number",
311                    "description": "Optional line limit for paged text reads."
312                }
313            },
314            "required": ["skill_id", "resource_path"]
315        })
316    }
317
318    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
319        self.execute_with_context(args, ToolExecutionContext::none("tool_call"))
320            .await
321    }
322
323    async fn execute_with_context(
324        &self,
325        args: serde_json::Value,
326        ctx: ToolExecutionContext<'_>,
327    ) -> Result<ToolResult, ToolError> {
328        let parsed: ReadSkillResourceArgs = serde_json::from_value(args).map_err(|err| {
329            ToolError::InvalidArguments(format!("Invalid read_skill_resource args: {err}"))
330        })?;
331        let skill_id = parsed.skill_id.trim();
332        if skill_id.is_empty() {
333            return Err(ToolError::InvalidArguments(
334                "skill_id must be a non-empty string".to_string(),
335            ));
336        }
337
338        access_control::ensure_skill_allowed(&self.access, skill_id, ctx.session_id)
339            .await
340            .map_err(skill_access_error_to_tool_error)?;
341        access_control::ensure_skill_loaded(&self.access, skill_id, ctx.session_id)
342            .await
343            .map_err(skill_access_error_to_tool_error)?;
344        let skill_mode = access_control::selected_skill_mode(&self.access, ctx.session_id).await;
345
346        let resource_path = normalize_relative_resource_path(&parsed.resource_path)
347            .map_err(ToolError::InvalidArguments)?;
348        if resource_path == Path::new("SKILL.md") {
349            return Err(ToolError::InvalidArguments(
350                "Use load_skill for SKILL.md instructions; read_skill_resource is for auxiliary files"
351                    .to_string(),
352            ));
353        }
354
355        let skill_root = self
356            .access
357            .skill_root(skill_id, skill_mode.as_deref())
358            .await?;
359        let canonical_root = tokio::fs::canonicalize(&skill_root).await.map_err(|_| {
360            ToolError::Execution(format!(
361                "Skill directory not found for '{skill_id}'. Load the skill list first."
362            ))
363        })?;
364        let canonical_resource = tokio::fs::canonicalize(skill_root.join(&resource_path))
365            .await
366            .map_err(|_| {
367                ToolError::Execution(format!(
368                    "Skill resource not found: {}/{}",
369                    skill_id,
370                    display_relative_path(&resource_path)
371                ))
372            })?;
373
374        if !canonical_resource.starts_with(&canonical_root) {
375            return Err(ToolError::InvalidArguments(
376                "resource_path must stay inside the skill directory".to_string(),
377            ));
378        }
379
380        let metadata = tokio::fs::metadata(&canonical_resource)
381            .await
382            .map_err(|err| ToolError::Execution(format!("Failed to stat resource: {err}")))?;
383        if !metadata.is_file() {
384            return Err(ToolError::InvalidArguments(format!(
385                "resource_path must reference a file: {}",
386                display_relative_path(&resource_path)
387            )));
388        }
389
390        let bytes = tokio::fs::read(&canonical_resource)
391            .await
392            .map_err(|err| ToolError::Execution(format!("Failed to read skill resource: {err}")))?;
393        let size_bytes = bytes.len();
394
395        let result = match String::from_utf8(bytes) {
396            Ok(text) => {
397                let offset = parsed.offset.unwrap_or(0);
398                let (paged, start, end, total_lines) = page_text_lines(&text, offset, parsed.limit);
399                let (excerpt, truncated) = truncate_text(&paged, MAX_RESOURCE_CONTENT_CHARS);
400                let has_more = end < total_lines;
401                let summary = json!({
402                    "skill_id": skill_id,
403                    "resource_path": display_relative_path(&resource_path),
404                    "offset": start,
405                    "limit": parsed.limit,
406                    "returned_lines": end.saturating_sub(start),
407                    "total_lines": total_lines,
408                    "has_more": has_more,
409                    "truncated": truncated,
410                    "binary": false
411                });
412                if let Some(session_id) = ctx.session_id {
413                    if let Some(mut session) =
414                        self.access.session_for_context(Some(session_id)).await
415                    {
416                        session.metadata.insert(
417                            LAST_RESOURCE_READ_SUMMARY_METADATA_KEY.to_string(),
418                            summary.to_string(),
419                        );
420                        let _ = self
421                            .access
422                            .persistence
423                            .merge_save_runtime(&mut session)
424                            .await;
425                        let mut sessions = self.access.sessions.write().await;
426                        sessions.insert(session_id.to_string(), session);
427                    }
428                }
429                json!({
430                    "skill_id": skill_id,
431                    "resource_path": display_relative_path(&resource_path),
432                    "size_bytes": size_bytes,
433                    "offset": start,
434                    "limit": parsed.limit,
435                    "returned_lines": end.saturating_sub(start),
436                    "total_lines": total_lines,
437                    "has_more": has_more,
438                    "next_offset": if has_more { Some(end) } else { None::<usize> },
439                    "truncated": truncated,
440                    "content": excerpt
441                })
442            }
443            Err(_) => json!({
444                "skill_id": skill_id,
445                "resource_path": display_relative_path(&resource_path),
446                "size_bytes": size_bytes,
447                "binary": true,
448                "message": "Resource is not UTF-8 text. Use file tools when binary handling is required."
449            }),
450        };
451
452        Ok(ToolResult {
453            success: true,
454            result: result.to_string(),
455            display_preference: Some("Collapsible".to_string()),
456        })
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::{LoadSkillTool, ReadSkillResourceTool};
463    use bamboo_engine::access_control::{parse_loaded_skill_ids, serialize_loaded_skill_ids};
464    use bamboo_engine::runtime_metadata::{
465        LAST_LOADED_SKILL_SUMMARY_METADATA_KEY, LAST_RESOURCE_READ_SUMMARY_METADATA_KEY,
466    };
467    use std::collections::{HashMap, HashSet};
468    use std::sync::Arc;
469
470    use tokio::sync::RwLock;
471
472    use bamboo_agent_core::storage::Storage;
473    use bamboo_agent_core::tools::{Tool, ToolExecutionContext};
474    use bamboo_agent_core::Session;
475    use bamboo_engine::{SkillManager, SkillStoreConfig};
476    use bamboo_infrastructure::Config;
477
478    #[test]
479    fn parse_loaded_skill_ids_supports_json_and_csv() {
480        let from_json = parse_loaded_skill_ids(r#"["skill-b","skill-a","skill-a"]"#);
481        assert_eq!(from_json.len(), 2);
482        assert!(from_json.contains("skill-a"));
483        assert!(from_json.contains("skill-b"));
484
485        let from_csv = parse_loaded_skill_ids("skill-c, skill-d , skill-c");
486        assert_eq!(from_csv.len(), 2);
487        assert!(from_csv.contains("skill-c"));
488        assert!(from_csv.contains("skill-d"));
489    }
490
491    #[test]
492    fn serialize_loaded_skill_ids_is_stable_and_sorted() {
493        let mut ids = HashSet::new();
494        ids.insert("skill-b".to_string());
495        ids.insert("skill-a".to_string());
496
497        assert_eq!(serialize_loaded_skill_ids(&ids), r#"["skill-a","skill-b"]"#);
498    }
499
500    #[derive(Default)]
501    struct TestStorage {
502        sessions: RwLock<HashMap<String, Session>>,
503    }
504
505    #[async_trait::async_trait]
506    impl Storage for TestStorage {
507        async fn save_session(&self, session: &Session) -> std::io::Result<()> {
508            self.sessions
509                .write()
510                .await
511                .insert(session.id.clone(), session.clone());
512            Ok(())
513        }
514
515        async fn load_session(&self, session_id: &str) -> std::io::Result<Option<Session>> {
516            Ok(self.sessions.read().await.get(session_id).cloned())
517        }
518
519        async fn delete_session(&self, session_id: &str) -> std::io::Result<bool> {
520            Ok(self.sessions.write().await.remove(session_id).is_some())
521        }
522    }
523
524    #[tokio::test]
525    async fn load_skill_rejects_globally_disabled_skill() {
526        let temp_dir = tempfile::tempdir().expect("tempdir should be created");
527        let skill_dir = temp_dir.path().join("skills").join("demo-skill");
528        std::fs::create_dir_all(&skill_dir).expect("skill dir should exist");
529        std::fs::write(
530            skill_dir.join("SKILL.md"),
531            r#"---
532name: demo-skill
533description: Demo description
534---
535Use this demo skill."#,
536        )
537        .expect("skill file should be written");
538
539        let skill_manager = Arc::new(SkillManager::with_config(SkillStoreConfig {
540            skills_dir: temp_dir.path().join("skills"),
541            project_dir: None,
542            active_mode: None,
543        }));
544        skill_manager
545            .initialize()
546            .await
547            .expect("skill manager should initialize");
548
549        let config = Arc::new(RwLock::new(Config::default()));
550        {
551            let mut cfg = config.write().await;
552            cfg.skills.disabled = vec!["demo-skill".to_string()];
553            cfg.normalize_skill_settings();
554        }
555
556        let session_id = "session-1";
557        let session = Session::new(session_id, "model");
558        let sessions = Arc::new(RwLock::new(HashMap::from([(
559            session_id.to_string(),
560            session.clone(),
561        )])));
562        let storage: Arc<dyn Storage> = Arc::new(TestStorage::default());
563        storage
564            .save_session(&session)
565            .await
566            .expect("session should be saved");
567
568        let persistence = Arc::new(bamboo_infrastructure::LockedSessionStore::new(
569            storage.clone(),
570        ));
571
572        let tool = LoadSkillTool::new(skill_manager, config, sessions, storage, persistence);
573        let ctx = ToolExecutionContext {
574            session_id: Some(session_id),
575            tool_call_id: "tool-call-1",
576            event_tx: None,
577            available_tool_schemas: None,
578        };
579
580        let error = tool
581            .execute_with_context(serde_json::json!({ "skill_id": "demo-skill" }), ctx)
582            .await
583            .expect_err("disabled skill should be rejected");
584
585        assert!(error
586            .to_string()
587            .contains("globally disabled in Bamboo settings"));
588    }
589
590    #[tokio::test]
591    async fn load_skill_persists_last_loaded_skill_summary() {
592        let temp_dir = tempfile::tempdir().expect("tempdir should be created");
593        let skill_dir = temp_dir.path().join("skills").join("demo-skill");
594        std::fs::create_dir_all(&skill_dir).expect("skill dir should exist");
595        std::fs::write(
596            skill_dir.join("SKILL.md"),
597            r#"---
598name: demo-skill
599description: Demo description
600---
601Use this demo skill."#,
602        )
603        .expect("skill file should be written");
604
605        let skill_manager = Arc::new(SkillManager::with_config(SkillStoreConfig {
606            skills_dir: temp_dir.path().join("skills"),
607            project_dir: None,
608            active_mode: None,
609        }));
610        skill_manager
611            .initialize()
612            .await
613            .expect("skill manager should initialize");
614
615        let config = Arc::new(RwLock::new(Config::default()));
616        let session_id = "session-2";
617        let session = Session::new(session_id, "model");
618        let sessions = Arc::new(RwLock::new(HashMap::from([(
619            session_id.to_string(),
620            session.clone(),
621        )])));
622        let storage: Arc<dyn Storage> = Arc::new(TestStorage::default());
623        storage
624            .save_session(&session)
625            .await
626            .expect("session should be saved");
627        let persistence = Arc::new(bamboo_infrastructure::LockedSessionStore::new(
628            storage.clone(),
629        ));
630
631        let tool = LoadSkillTool::new(
632            skill_manager,
633            config,
634            sessions.clone(),
635            storage.clone(),
636            persistence.clone(),
637        );
638        let ctx = ToolExecutionContext {
639            session_id: Some(session_id),
640            tool_call_id: "tool-call-2",
641            event_tx: None,
642            available_tool_schemas: None,
643        };
644
645        let _ = tool
646            .execute_with_context(serde_json::json!({ "skill_id": "demo-skill" }), ctx)
647            .await
648            .expect("load_skill should succeed");
649
650        let saved = storage
651            .load_session(session_id)
652            .await
653            .expect("load session should succeed")
654            .expect("session should exist");
655        let summary = saved
656            .metadata
657            .get(LAST_LOADED_SKILL_SUMMARY_METADATA_KEY)
658            .expect("last loaded skill summary should be present");
659        assert!(summary.contains("demo-skill"));
660    }
661
662    #[tokio::test]
663    async fn read_skill_resource_persists_last_resource_read_summary() {
664        let temp_dir = tempfile::tempdir().expect("tempdir should be created");
665        let skill_dir = temp_dir.path().join("skills").join("demo-skill");
666        let refs_dir = skill_dir.join("references");
667        std::fs::create_dir_all(&refs_dir).expect("references dir should exist");
668        std::fs::write(
669            skill_dir.join("SKILL.md"),
670            r#"---
671name: demo-skill
672description: Demo description
673---
674Use this demo skill."#,
675        )
676        .expect("skill file should be written");
677        std::fs::write(refs_dir.join("policy.md"), "line1\nline2\nline3\n")
678            .expect("resource file should be written");
679
680        let skill_manager = Arc::new(SkillManager::with_config(SkillStoreConfig {
681            skills_dir: temp_dir.path().join("skills"),
682            project_dir: None,
683            active_mode: None,
684        }));
685        skill_manager
686            .initialize()
687            .await
688            .expect("skill manager should initialize");
689
690        let config = Arc::new(RwLock::new(Config::default()));
691        let session_id = "session-3";
692        let session = Session::new(session_id, "model");
693        let sessions = Arc::new(RwLock::new(HashMap::from([(
694            session_id.to_string(),
695            session.clone(),
696        )])));
697        let storage: Arc<dyn Storage> = Arc::new(TestStorage::default());
698        storage
699            .save_session(&session)
700            .await
701            .expect("session should be saved");
702        let persistence = Arc::new(bamboo_infrastructure::LockedSessionStore::new(
703            storage.clone(),
704        ));
705
706        let load_tool = LoadSkillTool::new(
707            skill_manager.clone(),
708            config.clone(),
709            sessions.clone(),
710            storage.clone(),
711            persistence.clone(),
712        );
713        let read_tool = ReadSkillResourceTool::new(
714            skill_manager,
715            config,
716            sessions,
717            storage.clone(),
718            persistence,
719        );
720
721        let load_ctx = ToolExecutionContext {
722            session_id: Some(session_id),
723            tool_call_id: "tool-call-load",
724            event_tx: None,
725            available_tool_schemas: None,
726        };
727        let read_ctx = ToolExecutionContext {
728            session_id: Some(session_id),
729            tool_call_id: "tool-call-read",
730            event_tx: None,
731            available_tool_schemas: None,
732        };
733
734        let _ = load_tool
735            .execute_with_context(serde_json::json!({ "skill_id": "demo-skill" }), load_ctx)
736            .await
737            .expect("load_skill should succeed");
738
739        let _ = read_tool
740            .execute_with_context(
741                serde_json::json!({
742                    "skill_id": "demo-skill",
743                    "resource_path": "references/policy.md",
744                    "offset": 1,
745                    "limit": 1
746                }),
747                read_ctx,
748            )
749            .await
750            .expect("read_skill_resource should succeed");
751
752        let saved = storage
753            .load_session(session_id)
754            .await
755            .expect("load session should succeed")
756            .expect("session should exist");
757        let summary = saved
758            .metadata
759            .get(LAST_RESOURCE_READ_SUMMARY_METADATA_KEY)
760            .expect("last resource read summary should be present");
761        assert!(summary.contains("demo-skill"));
762        assert!(summary.contains("references/policy.md"));
763        assert!(summary.contains("\"offset\":1"));
764    }
765}