robot_lab 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. checksums.yaml +4 -4
  2. data/.architecture/AGENTS.md +32 -0
  3. data/.architecture/config.yml +8 -0
  4. data/.architecture/members.yml +60 -0
  5. data/.architecture/reviews/feature-free-will.md +490 -0
  6. data/.architecture/reviews/overall-codebase.md +427 -0
  7. data/.claude/settings.local.json +57 -0
  8. data/.codex/config.toml +2 -0
  9. data/.irbrc +2 -2
  10. data/.rubocop.yml +172 -0
  11. data/CHANGELOG.md +72 -0
  12. data/CLAUDE.md +139 -0
  13. data/README.md +91 -95
  14. data/Rakefile +109 -3
  15. data/agent2agent_review.md +192 -0
  16. data/agentf_improvements.md +253 -0
  17. data/agents.md +14 -0
  18. data/docs/examples/index.md +37 -2
  19. data/docs/getting-started/configuration.md +20 -7
  20. data/docs/guides/index.md +16 -16
  21. data/docs/guides/knowledge.md +7 -1
  22. data/docs/guides/observability.md +132 -0
  23. data/docs/index.md +30 -3
  24. data/docs/superpowers/plans/2026-05-06-agentskills.md +1303 -0
  25. data/docs/superpowers/specs/2026-05-06-agentskills-design.md +247 -0
  26. data/examples/.envrc +1 -0
  27. data/examples/01_simple_robot.rb +5 -9
  28. data/examples/02_tools.rb +5 -9
  29. data/examples/03_network.rb +8 -9
  30. data/examples/04_mcp.rb +21 -29
  31. data/examples/05_streaming.rb +12 -18
  32. data/examples/06_prompt_templates.rb +11 -19
  33. data/examples/07_network_memory.rb +16 -31
  34. data/examples/08_llm_config.rb +10 -22
  35. data/examples/09_chaining.rb +16 -27
  36. data/examples/10_memory.rb +12 -28
  37. data/examples/11_network_introspection.rb +15 -29
  38. data/examples/12_message_bus.rb +5 -12
  39. data/examples/13_spawn.rb +5 -10
  40. data/examples/14_rusty_circuit/.envrc +1 -0
  41. data/examples/14_rusty_circuit/comic.rb +2 -0
  42. data/examples/14_rusty_circuit/heckler.rb +1 -1
  43. data/examples/14_rusty_circuit/open_mic.rb +1 -3
  44. data/examples/14_rusty_circuit/scout.rb +2 -0
  45. data/examples/15_memory_network_and_bus/.envrc +1 -0
  46. data/examples/15_memory_network_and_bus/editorial_pipeline.rb +6 -3
  47. data/examples/15_memory_network_and_bus/linux_writer.rb +1 -1
  48. data/examples/15_memory_network_and_bus/output/combined_article.md +6 -6
  49. data/examples/15_memory_network_and_bus/output/final_article.md +6 -8
  50. data/examples/15_memory_network_and_bus/output/linux_draft.md +4 -2
  51. data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
  52. data/examples/15_memory_network_and_bus/output/memory.json +6 -6
  53. data/examples/15_memory_network_and_bus/output/revision_1.md +10 -11
  54. data/examples/15_memory_network_and_bus/output/revision_2.md +6 -8
  55. data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
  56. data/examples/16_writers_room/.envrc +1 -0
  57. data/examples/16_writers_room/writers_room.rb +2 -4
  58. data/examples/17_skills.rb +8 -17
  59. data/examples/18_rails/Gemfile +1 -0
  60. data/examples/19_token_tracking.rb +9 -15
  61. data/examples/20_circuit_breaker.rb +10 -19
  62. data/examples/21_learning_loop.rb +11 -20
  63. data/examples/22_context_compression.rb +6 -13
  64. data/examples/23_convergence.rb +6 -17
  65. data/examples/24_structured_delegation.rb +11 -15
  66. data/examples/25_history_search.rb +5 -12
  67. data/examples/26_document_store.rb +6 -13
  68. data/examples/27_incident_response/incident_response.rb +4 -5
  69. data/examples/28_mcp_discovery.rb +8 -11
  70. data/examples/29_ractor_tools.rb +4 -9
  71. data/examples/30_ractor_network.rb +10 -19
  72. data/examples/31_launch_assessment.rb +10 -23
  73. data/examples/32_newsletter_reader.rb +188 -0
  74. data/examples/33_stock_generator.rb +80 -0
  75. data/examples/33_stock_predictor.rb +306 -0
  76. data/examples/34_agentskills.rb +72 -0
  77. data/examples/README.md +1 -1
  78. data/examples/common.rb +76 -0
  79. data/examples/ruboruby.md +423 -0
  80. data/examples/temp.md +51 -0
  81. data/lib/robot_lab/agent_skill.rb +63 -0
  82. data/lib/robot_lab/agent_skill_catalog.rb +74 -0
  83. data/lib/robot_lab/ask_user.rb +2 -2
  84. data/lib/robot_lab/bus_poller.rb +12 -5
  85. data/lib/robot_lab/config.rb +1 -12
  86. data/lib/robot_lab/delegation_future.rb +1 -1
  87. data/lib/robot_lab/doom_loop_detector.rb +98 -0
  88. data/lib/robot_lab/history_compressor.rb +4 -10
  89. data/lib/robot_lab/mcp/client.rb +1 -2
  90. data/lib/robot_lab/mcp/connection_poller.rb +3 -3
  91. data/lib/robot_lab/mcp/server.rb +1 -1
  92. data/lib/robot_lab/mcp/server_discovery.rb +0 -2
  93. data/lib/robot_lab/memory.rb +32 -27
  94. data/lib/robot_lab/memory_change.rb +2 -2
  95. data/lib/robot_lab/message.rb +4 -4
  96. data/lib/robot_lab/network.rb +11 -6
  97. data/lib/robot_lab/robot/agent_skill_matching.rb +99 -0
  98. data/lib/robot_lab/robot/bus_messaging.rb +9 -27
  99. data/lib/robot_lab/robot/history_search.rb +4 -1
  100. data/lib/robot_lab/robot/mcp_management.rb +5 -11
  101. data/lib/robot_lab/robot/template_rendering.rb +60 -40
  102. data/lib/robot_lab/robot.rb +323 -206
  103. data/lib/robot_lab/robot_result.rb +6 -5
  104. data/lib/robot_lab/run_config.rb +5 -11
  105. data/lib/robot_lab/script_tool.rb +76 -0
  106. data/lib/robot_lab/state_proxy.rb +7 -5
  107. data/lib/robot_lab/tool.rb +3 -3
  108. data/lib/robot_lab/tool_config.rb +1 -1
  109. data/lib/robot_lab/tool_manifest.rb +5 -7
  110. data/lib/robot_lab/user_message.rb +2 -2
  111. data/lib/robot_lab/version.rb +1 -1
  112. data/lib/robot_lab/waiter.rb +1 -1
  113. data/lib/robot_lab.rb +41 -52
  114. data/logfile +8 -0
  115. data/mkdocs.yml +2 -3
  116. data/robot_concurrency.md +38 -0
  117. data/simple_acp_review.md +298 -0
  118. data/site/404.html +2300 -0
  119. data/site/api/core/index.html +2706 -0
  120. data/site/api/core/memory/index.html +3793 -0
  121. data/site/api/core/network/index.html +3500 -0
  122. data/site/api/core/robot/index.html +4566 -0
  123. data/site/api/core/state/index.html +3390 -0
  124. data/site/api/core/tool/index.html +3843 -0
  125. data/site/api/index.html +2635 -0
  126. data/site/api/mcp/client/index.html +3435 -0
  127. data/site/api/mcp/index.html +2783 -0
  128. data/site/api/mcp/server/index.html +3252 -0
  129. data/site/api/mcp/transports/index.html +3352 -0
  130. data/site/api/messages/index.html +2641 -0
  131. data/site/api/messages/text-message/index.html +3087 -0
  132. data/site/api/messages/tool-call-message/index.html +3159 -0
  133. data/site/api/messages/tool-result-message/index.html +3252 -0
  134. data/site/api/messages/user-message/index.html +3212 -0
  135. data/site/api/streaming/context/index.html +3282 -0
  136. data/site/api/streaming/events/index.html +3347 -0
  137. data/site/api/streaming/index.html +2738 -0
  138. data/site/architecture/core-concepts/index.html +3757 -0
  139. data/site/architecture/index.html +2797 -0
  140. data/site/architecture/message-flow/index.html +3238 -0
  141. data/site/architecture/network-orchestration/index.html +3433 -0
  142. data/site/architecture/robot-execution/index.html +3140 -0
  143. data/site/architecture/state-management/index.html +3498 -0
  144. data/site/assets/css/custom.css +56 -0
  145. data/site/assets/images/favicon.png +0 -0
  146. data/site/assets/images/robot_lab.jpg +0 -0
  147. data/site/assets/javascripts/bundle.79ae519e.min.js +16 -0
  148. data/site/assets/javascripts/bundle.79ae519e.min.js.map +7 -0
  149. data/site/assets/javascripts/lunr/min/lunr.ar.min.js +1 -0
  150. data/site/assets/javascripts/lunr/min/lunr.da.min.js +18 -0
  151. data/site/assets/javascripts/lunr/min/lunr.de.min.js +18 -0
  152. data/site/assets/javascripts/lunr/min/lunr.du.min.js +18 -0
  153. data/site/assets/javascripts/lunr/min/lunr.el.min.js +1 -0
  154. data/site/assets/javascripts/lunr/min/lunr.es.min.js +18 -0
  155. data/site/assets/javascripts/lunr/min/lunr.fi.min.js +18 -0
  156. data/site/assets/javascripts/lunr/min/lunr.fr.min.js +18 -0
  157. data/site/assets/javascripts/lunr/min/lunr.he.min.js +1 -0
  158. data/site/assets/javascripts/lunr/min/lunr.hi.min.js +1 -0
  159. data/site/assets/javascripts/lunr/min/lunr.hu.min.js +18 -0
  160. data/site/assets/javascripts/lunr/min/lunr.hy.min.js +1 -0
  161. data/site/assets/javascripts/lunr/min/lunr.it.min.js +18 -0
  162. data/site/assets/javascripts/lunr/min/lunr.ja.min.js +1 -0
  163. data/site/assets/javascripts/lunr/min/lunr.jp.min.js +1 -0
  164. data/site/assets/javascripts/lunr/min/lunr.kn.min.js +1 -0
  165. data/site/assets/javascripts/lunr/min/lunr.ko.min.js +1 -0
  166. data/site/assets/javascripts/lunr/min/lunr.multi.min.js +1 -0
  167. data/site/assets/javascripts/lunr/min/lunr.nl.min.js +18 -0
  168. data/site/assets/javascripts/lunr/min/lunr.no.min.js +18 -0
  169. data/site/assets/javascripts/lunr/min/lunr.pt.min.js +18 -0
  170. data/site/assets/javascripts/lunr/min/lunr.ro.min.js +18 -0
  171. data/site/assets/javascripts/lunr/min/lunr.ru.min.js +18 -0
  172. data/site/assets/javascripts/lunr/min/lunr.sa.min.js +1 -0
  173. data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +1 -0
  174. data/site/assets/javascripts/lunr/min/lunr.sv.min.js +18 -0
  175. data/site/assets/javascripts/lunr/min/lunr.ta.min.js +1 -0
  176. data/site/assets/javascripts/lunr/min/lunr.te.min.js +1 -0
  177. data/site/assets/javascripts/lunr/min/lunr.th.min.js +1 -0
  178. data/site/assets/javascripts/lunr/min/lunr.tr.min.js +18 -0
  179. data/site/assets/javascripts/lunr/min/lunr.vi.min.js +1 -0
  180. data/site/assets/javascripts/lunr/min/lunr.zh.min.js +1 -0
  181. data/site/assets/javascripts/lunr/tinyseg.js +206 -0
  182. data/site/assets/javascripts/lunr/wordcut.js +6708 -0
  183. data/site/assets/javascripts/workers/search.2c215733.min.js +42 -0
  184. data/site/assets/javascripts/workers/search.2c215733.min.js.map +7 -0
  185. data/site/assets/stylesheets/main.484c7ddc.min.css +1 -0
  186. data/site/assets/stylesheets/main.484c7ddc.min.css.map +1 -0
  187. data/site/assets/stylesheets/palette.ab4e12ef.min.css +1 -0
  188. data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +1 -0
  189. data/site/concepts/index.html +3455 -0
  190. data/site/examples/basic-chat/index.html +2880 -0
  191. data/site/examples/index.html +2907 -0
  192. data/site/examples/mcp-server/index.html +3018 -0
  193. data/site/examples/multi-robot-network/index.html +3131 -0
  194. data/site/examples/rails-application/index.html +3329 -0
  195. data/site/examples/tool-usage/index.html +3085 -0
  196. data/site/getting-started/configuration/index.html +3745 -0
  197. data/site/getting-started/index.html +2572 -0
  198. data/site/getting-started/installation/index.html +2981 -0
  199. data/site/getting-started/quick-start/index.html +2942 -0
  200. data/site/guides/building-robots/index.html +4290 -0
  201. data/site/guides/creating-networks/index.html +3858 -0
  202. data/site/guides/index.html +2586 -0
  203. data/site/guides/mcp-integration/index.html +3581 -0
  204. data/site/guides/memory/index.html +3586 -0
  205. data/site/guides/rails-integration/index.html +4019 -0
  206. data/site/guides/streaming/index.html +3157 -0
  207. data/site/guides/using-tools/index.html +3802 -0
  208. data/site/index.html +2671 -0
  209. data/site/search/search_index.json +1 -0
  210. data/site/sitemap.xml +183 -0
  211. data/site/sitemap.xml.gz +0 -0
  212. data/site/tags.json +1 -0
  213. data/temp.md +6 -0
  214. data/tool_manifest_plan.md +155 -0
  215. metadata +154 -92
  216. data/docs/examples/rails-application.md +0 -419
  217. data/docs/guides/ractor-parallelism.md +0 -364
  218. data/docs/guides/rails-integration.md +0 -681
  219. data/docs/superpowers/plans/2026-04-14-ractor-integration.md +0 -1538
  220. data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +0 -258
  221. data/lib/generators/robot_lab/install_generator.rb +0 -90
  222. data/lib/generators/robot_lab/job_generator.rb +0 -40
  223. data/lib/generators/robot_lab/robot_generator.rb +0 -55
  224. data/lib/generators/robot_lab/templates/initializer.rb.tt +0 -42
  225. data/lib/generators/robot_lab/templates/job.rb.tt +0 -21
  226. data/lib/generators/robot_lab/templates/migration.rb.tt +0 -32
  227. data/lib/generators/robot_lab/templates/result_model.rb.tt +0 -52
  228. data/lib/generators/robot_lab/templates/robot.rb.tt +0 -31
  229. data/lib/generators/robot_lab/templates/robot_job.rb.tt +0 -18
  230. data/lib/generators/robot_lab/templates/robot_test.rb.tt +0 -34
  231. data/lib/generators/robot_lab/templates/routing_robot.rb.tt +0 -59
  232. data/lib/generators/robot_lab/templates/thread_model.rb.tt +0 -40
  233. data/lib/robot_lab/document_store.rb +0 -155
  234. data/lib/robot_lab/ractor_boundary.rb +0 -42
  235. data/lib/robot_lab/ractor_job.rb +0 -37
  236. data/lib/robot_lab/ractor_memory_proxy.rb +0 -85
  237. data/lib/robot_lab/ractor_network_scheduler.rb +0 -154
  238. data/lib/robot_lab/ractor_worker_pool.rb +0 -117
  239. data/lib/robot_lab/rails_integration/engine.rb +0 -29
  240. data/lib/robot_lab/rails_integration/job.rb +0 -158
  241. data/lib/robot_lab/rails_integration/railtie.rb +0 -51
  242. data/lib/robot_lab/rails_integration/turbo_stream_callbacks.rb +0 -72
@@ -0,0 +1,1303 @@
1
+ # AgentSkills.io Support Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Extend the existing `skills:` constructor param to also recognize AgentSkills.io folder format (`~/.prompts/skills/<name>/SKILL.md`), with runtime embedding-based injection and `scripts/` auto-wrapped as tools.
6
+
7
+ **Architecture:** Format detection happens in `expand_skills` — PM templates continue as before; AgentSkill folders are stored in `@pending_agent_skills`. Before each `run()` call, a prepended `AgentSkillMatching` module embeds the message (via `DocumentStore`/fastembed), scores pending skills by cosine similarity, and injects matching skills' instructions + script tools for that call only, restoring everything in an `ensure` block.
8
+
9
+ **Tech Stack:** fastembed (via existing `DocumentStore`), `open3` (stdlib), YAML (stdlib), Minitest
10
+
11
+ ---
12
+
13
+ ## File Map
14
+
15
+ | Action | Path | Responsibility |
16
+ |--------|------|----------------|
17
+ | Create | `lib/robot_lab/agent_skill.rb` | Value object: parse SKILL.md, expose instructions + scripts |
18
+ | Create | `lib/robot_lab/agent_skill_catalog.rb` | Singleton: scan `~/.prompts/skills/`, look up by ID |
19
+ | Create | `lib/robot_lab/script_tool.rb` | Factory module: wrap a shell script as a `RobotLab::Tool` |
20
+ | Create | `lib/robot_lab/robot/agent_skill_matching.rb` | Prepended module: `run()` override for runtime injection |
21
+ | Modify | `lib/robot_lab/robot/template_rendering.rb` | `expand_skills`: detect catalog hit before PM lookup |
22
+ | Modify | `lib/robot_lab/robot.rb` | Initialize `@pending_agent_skills` / `@agent_skill_store`; prepend matching module |
23
+ | Create | `test/robot_lab/agent_skill_test.rb` | Unit tests for AgentSkill |
24
+ | Create | `test/robot_lab/agent_skill_catalog_test.rb` | Unit tests for AgentSkillCatalog |
25
+ | Create | `test/robot_lab/script_tool_test.rb` | Unit tests for ScriptTool |
26
+ | Create | `test/robot_lab/robot/agent_skill_matching_test.rb` | Unit tests for AgentSkillMatching |
27
+ | Create | `test/fixtures/skills/test_skill/SKILL.md` | Valid skill fixture |
28
+ | Create | `test/fixtures/skills/bad_skill/SKILL.md` | Missing-description fixture |
29
+ | Create | `test/fixtures/skills/scripted_skill/SKILL.md` | Skill with scripts fixture |
30
+ | Create | `test/fixtures/skills/scripted_skill/scripts/hello.sh` | Executable script fixture |
31
+
32
+ ---
33
+
34
+ ## Task 1: Test fixtures
35
+
36
+ **Files:**
37
+ - Create: `test/fixtures/skills/test_skill/SKILL.md`
38
+ - Create: `test/fixtures/skills/bad_skill/SKILL.md`
39
+ - Create: `test/fixtures/skills/scripted_skill/SKILL.md`
40
+ - Create: `test/fixtures/skills/scripted_skill/scripts/hello.sh`
41
+
42
+ - [ ] **Step 1: Create fixture directories and SKILL.md files**
43
+
44
+ `test/fixtures/skills/test_skill/SKILL.md`:
45
+ ```markdown
46
+ ---
47
+ name: test_skill
48
+ description: A test skill for verifying AgentSkills.io integration
49
+ ---
50
+ You have been enhanced with the test skill. Apply rigorous testing practices.
51
+ ```
52
+
53
+ `test/fixtures/skills/bad_skill/SKILL.md`:
54
+ ```markdown
55
+ ---
56
+ name: bad_skill
57
+ ---
58
+ This skill is missing a description field.
59
+ ```
60
+
61
+ `test/fixtures/skills/scripted_skill/SKILL.md`:
62
+ ```markdown
63
+ ---
64
+ name: scripted_skill
65
+ description: A skill that includes a bundled shell script
66
+ ---
67
+ You have access to the hello script tool. Use it to greet users.
68
+ ```
69
+
70
+ - [ ] **Step 2: Create the executable script fixture**
71
+
72
+ `test/fixtures/skills/scripted_skill/scripts/hello.sh`:
73
+ ```bash
74
+ #!/usr/bin/env bash
75
+ # Say hello to the world
76
+ echo "Hello from AgentSkills script!"
77
+ ```
78
+
79
+ Then make it executable:
80
+ ```bash
81
+ chmod +x test/fixtures/skills/scripted_skill/scripts/hello.sh
82
+ ```
83
+
84
+ - [ ] **Step 3: Commit fixtures**
85
+
86
+ ```bash
87
+ git add test/fixtures/skills/
88
+ git commit -m "test: add AgentSkills.io fixture files"
89
+ ```
90
+
91
+ ---
92
+
93
+ ## Task 2: AgentSkill value object
94
+
95
+ **Files:**
96
+ - Create: `lib/robot_lab/agent_skill.rb`
97
+ - Create: `test/robot_lab/agent_skill_test.rb`
98
+
99
+ - [ ] **Step 1: Write the failing tests**
100
+
101
+ `test/robot_lab/agent_skill_test.rb`:
102
+ ```ruby
103
+ # frozen_string_literal: true
104
+
105
+ require "test_helper"
106
+
107
+ module RobotLab
108
+ class AgentSkillTest < Minitest::Test
109
+ FIXTURES = Pathname.new(File.expand_path("../../fixtures/skills", __dir__))
110
+
111
+ def test_parses_name_and_description
112
+ skill = AgentSkill.new(FIXTURES.join("test_skill", "SKILL.md"))
113
+ assert_equal "test_skill", skill.name
114
+ assert_equal "A test skill for verifying AgentSkills.io integration", skill.description
115
+ end
116
+
117
+ def test_parses_instructions_body
118
+ skill = AgentSkill.new(FIXTURES.join("test_skill", "SKILL.md"))
119
+ assert_includes skill.instructions, "rigorous testing practices"
120
+ end
121
+
122
+ def test_path_is_the_skill_directory
123
+ skill = AgentSkill.new(FIXTURES.join("test_skill", "SKILL.md"))
124
+ assert_equal FIXTURES.join("test_skill"), skill.path
125
+ end
126
+
127
+ def test_raises_configuration_error_when_description_missing
128
+ assert_raises(ConfigurationError) do
129
+ AgentSkill.new(FIXTURES.join("bad_skill", "SKILL.md"))
130
+ end
131
+ end
132
+
133
+ def test_scripts_returns_empty_when_no_scripts_directory
134
+ skill = AgentSkill.new(FIXTURES.join("test_skill", "SKILL.md"))
135
+ assert_equal [], skill.scripts
136
+ end
137
+
138
+ def test_scripts_returns_files_from_scripts_directory
139
+ skill = AgentSkill.new(FIXTURES.join("scripted_skill", "SKILL.md"))
140
+ assert_equal 1, skill.scripts.length
141
+ assert_equal "hello.sh", skill.scripts.first.basename.to_s
142
+ end
143
+
144
+ def test_script_tools_returns_empty_for_no_scripts
145
+ skill = AgentSkill.new(FIXTURES.join("test_skill", "SKILL.md"))
146
+ assert_equal [], skill.script_tools
147
+ end
148
+
149
+ def test_script_tools_wraps_executable_scripts
150
+ skill = AgentSkill.new(FIXTURES.join("scripted_skill", "SKILL.md"))
151
+ tools = skill.script_tools
152
+ assert_equal 1, tools.length
153
+ assert_equal "hello", tools.first.name
154
+ end
155
+ end
156
+ end
157
+ ```
158
+
159
+ - [ ] **Step 2: Run to verify failure**
160
+
161
+ ```bash
162
+ bundle exec rake test_file[robot_lab/agent_skill_test.rb]
163
+ ```
164
+ Expected: `NameError: uninitialized constant RobotLab::AgentSkill`
165
+
166
+ - [ ] **Step 3: Implement AgentSkill**
167
+
168
+ `lib/robot_lab/agent_skill.rb`:
169
+ ```ruby
170
+ # frozen_string_literal: true
171
+
172
+ require "yaml"
173
+ require "pathname"
174
+
175
+ module RobotLab
176
+ # Value object representing an AgentSkills.io skill folder.
177
+ #
178
+ # A skill is a directory containing SKILL.md with required front matter
179
+ # fields (name, description) and optional scripts/, references/, assets/.
180
+ class AgentSkill
181
+ attr_reader :name, :description, :path
182
+
183
+ # @param skill_md_path [String, Pathname] path to the SKILL.md file
184
+ # @raise [ConfigurationError] if name or description is missing
185
+ def initialize(skill_md_path)
186
+ @path = Pathname.new(skill_md_path).dirname
187
+ content = File.read(skill_md_path)
188
+ front_matter, @_body = parse_skill_md(content)
189
+
190
+ @name = front_matter["name"]
191
+ @description = front_matter["description"]
192
+
193
+ raise ConfigurationError, "SKILL.md at #{skill_md_path} missing 'name'" unless @name
194
+ raise ConfigurationError, "SKILL.md at #{skill_md_path} missing 'description'" unless @description
195
+ end
196
+
197
+ # Full instruction text from the SKILL.md body (below the front matter).
198
+ #
199
+ # @return [String]
200
+ def instructions
201
+ @_body.strip
202
+ end
203
+
204
+ # Pathnames of all files inside the scripts/ subdirectory, sorted.
205
+ #
206
+ # @return [Array<Pathname>]
207
+ def scripts
208
+ @scripts ||= begin
209
+ dir = @path.join("scripts")
210
+ dir.directory? ? dir.children.select(&:file?).sort : []
211
+ end
212
+ end
213
+
214
+ # RobotLab::Tool instances wrapping each executable script.
215
+ # Non-executable scripts are skipped with a warning.
216
+ #
217
+ # @return [Array<RobotLab::Tool>]
218
+ def script_tools
219
+ @script_tools ||= scripts.filter_map do |script_path|
220
+ ScriptTool.from_path(script_path)
221
+ end
222
+ end
223
+
224
+ private
225
+
226
+ # Split SKILL.md content into front matter Hash and body String.
227
+ def parse_skill_md(content)
228
+ if content.start_with?("---\n")
229
+ parts = content.split(/^---\s*$/, 3)
230
+ if parts.length >= 3
231
+ front_matter = YAML.safe_load(parts[1]) || {}
232
+ return [front_matter, parts[2]]
233
+ end
234
+ end
235
+ [{}, content]
236
+ end
237
+ end
238
+ end
239
+ ```
240
+
241
+ - [ ] **Step 4: Run tests to verify they pass**
242
+
243
+ ```bash
244
+ bundle exec rake test_file[robot_lab/agent_skill_test.rb]
245
+ ```
246
+ Expected: 8 tests pass. The `script_tools` tests will fail until ScriptTool exists (Task 4). If so, stub with `skip` or implement ScriptTool first.
247
+
248
+ > **Note:** If `test_script_tools_*` tests fail due to missing ScriptTool, proceed to Task 4 first, then re-run this suite.
249
+
250
+ - [ ] **Step 5: Commit**
251
+
252
+ ```bash
253
+ git add lib/robot_lab/agent_skill.rb test/robot_lab/agent_skill_test.rb
254
+ git commit -m "feat(agent_skill): add AgentSkill value object with SKILL.md parsing"
255
+ ```
256
+
257
+ ---
258
+
259
+ ## Task 3: AgentSkillCatalog
260
+
261
+ **Files:**
262
+ - Create: `lib/robot_lab/agent_skill_catalog.rb`
263
+ - Create: `test/robot_lab/agent_skill_catalog_test.rb`
264
+
265
+ - [ ] **Step 1: Write the failing tests**
266
+
267
+ `test/robot_lab/agent_skill_catalog_test.rb`:
268
+ ```ruby
269
+ # frozen_string_literal: true
270
+
271
+ require "test_helper"
272
+
273
+ module RobotLab
274
+ class AgentSkillCatalogTest < Minitest::Test
275
+ FIXTURES = File.expand_path("../../fixtures/skills", __dir__)
276
+
277
+ def setup
278
+ @catalog = AgentSkillCatalog.new(FIXTURES)
279
+ end
280
+
281
+ def test_find_returns_agent_skill_by_symbol
282
+ skill = @catalog.find(:test_skill)
283
+ refute_nil skill
284
+ assert_equal "test_skill", skill.name
285
+ end
286
+
287
+ def test_find_returns_agent_skill_by_string
288
+ skill = @catalog.find("test_skill")
289
+ refute_nil skill
290
+ assert_equal "test_skill", skill.name
291
+ end
292
+
293
+ def test_find_returns_nil_for_unknown_id
294
+ assert_nil @catalog.find(:nonexistent)
295
+ end
296
+
297
+ def test_all_returns_all_discovered_skills
298
+ skills = @catalog.all
299
+ names = skills.map(&:name)
300
+ assert_includes names, "test_skill"
301
+ assert_includes names, "scripted_skill"
302
+ end
303
+
304
+ def test_bad_skill_is_skipped_not_raised
305
+ # bad_skill has no description; catalog should skip it with a warning
306
+ skill = @catalog.find(:bad_skill)
307
+ assert_nil skill
308
+ end
309
+
310
+ def test_instance_returns_same_object
311
+ first = AgentSkillCatalog.instance
312
+ second = AgentSkillCatalog.instance
313
+ assert_same first, second
314
+ end
315
+
316
+ def test_reset_clears_instance
317
+ AgentSkillCatalog.instance
318
+ AgentSkillCatalog.reset!
319
+ # After reset, accessing instance again should return a new object
320
+ assert_instance_of AgentSkillCatalog, AgentSkillCatalog.instance
321
+ ensure
322
+ AgentSkillCatalog.reset!
323
+ end
324
+ end
325
+ end
326
+ ```
327
+
328
+ - [ ] **Step 2: Run to verify failure**
329
+
330
+ ```bash
331
+ bundle exec rake test_file[robot_lab/agent_skill_catalog_test.rb]
332
+ ```
333
+ Expected: `NameError: uninitialized constant RobotLab::AgentSkillCatalog`
334
+
335
+ - [ ] **Step 3: Implement AgentSkillCatalog**
336
+
337
+ `lib/robot_lab/agent_skill_catalog.rb`:
338
+ ```ruby
339
+ # frozen_string_literal: true
340
+
341
+ require "pathname"
342
+
343
+ module RobotLab
344
+ # Singleton registry that scans ~/.prompts/skills/ and provides
345
+ # AgentSkill lookup by ID.
346
+ #
347
+ # Skills are loaded lazily on first access (thread-safe via Mutex).
348
+ # Bad SKILL.md files (missing name/description) are skipped with a warning.
349
+ class AgentSkillCatalog
350
+ SKILLS_ROOT = Pathname.new(File.expand_path("~/.prompts/skills"))
351
+
352
+ class << self
353
+ # The process-level singleton instance (uses SKILLS_ROOT).
354
+ #
355
+ # @return [AgentSkillCatalog]
356
+ def instance
357
+ @instance ||= new(SKILLS_ROOT)
358
+ end
359
+
360
+ # Reset the singleton (used in tests to swap the skills root).
361
+ def reset!
362
+ @instance = nil
363
+ end
364
+ end
365
+
366
+ # @param skills_root [String, Pathname] directory to scan for skill folders
367
+ def initialize(skills_root = SKILLS_ROOT)
368
+ @skills_root = Pathname.new(skills_root)
369
+ @skills = {}
370
+ @mutex = Mutex.new
371
+ @loaded = false
372
+ end
373
+
374
+ # Return the AgentSkill for the given ID, or nil if not found.
375
+ #
376
+ # @param id [Symbol, String] skill folder name
377
+ # @return [AgentSkill, nil]
378
+ def find(id)
379
+ load!
380
+ @skills[id.to_sym]
381
+ end
382
+
383
+ # All discovered AgentSkill objects.
384
+ #
385
+ # @return [Array<AgentSkill>]
386
+ def all
387
+ load!
388
+ @skills.values
389
+ end
390
+
391
+ private
392
+
393
+ def load!
394
+ @mutex.synchronize { load_skills! unless @loaded }
395
+ end
396
+
397
+ def load_skills!
398
+ @loaded = true
399
+ return unless @skills_root.directory?
400
+
401
+ @skills_root.each_child do |dir|
402
+ next unless dir.directory?
403
+
404
+ skill_file = dir.join("SKILL.md")
405
+ next unless skill_file.exist?
406
+
407
+ begin
408
+ skill = AgentSkill.new(skill_file)
409
+ @skills[skill.name.to_sym] = skill
410
+ rescue ConfigurationError => e
411
+ RobotLab.config.logger.warn("AgentSkillCatalog: #{e.message}, skipping #{dir.basename}")
412
+ end
413
+ end
414
+ end
415
+ end
416
+ end
417
+ ```
418
+
419
+ - [ ] **Step 4: Run tests to verify they pass**
420
+
421
+ ```bash
422
+ bundle exec rake test_file[robot_lab/agent_skill_catalog_test.rb]
423
+ ```
424
+ Expected: 7 tests pass.
425
+
426
+ - [ ] **Step 5: Commit**
427
+
428
+ ```bash
429
+ git add lib/robot_lab/agent_skill_catalog.rb test/robot_lab/agent_skill_catalog_test.rb
430
+ git commit -m "feat(agent_skill_catalog): add AgentSkillCatalog for skill discovery"
431
+ ```
432
+
433
+ ---
434
+
435
+ ## Task 4: ScriptTool factory
436
+
437
+ **Files:**
438
+ - Create: `lib/robot_lab/script_tool.rb`
439
+ - Create: `test/robot_lab/script_tool_test.rb`
440
+
441
+ - [ ] **Step 1: Write the failing tests**
442
+
443
+ `test/robot_lab/script_tool_test.rb`:
444
+ ```ruby
445
+ # frozen_string_literal: true
446
+
447
+ require "test_helper"
448
+
449
+ module RobotLab
450
+ class ScriptToolTest < Minitest::Test
451
+ FIXTURE_SCRIPT = File.expand_path(
452
+ "../../fixtures/skills/scripted_skill/scripts/hello.sh", __dir__
453
+ )
454
+
455
+ def test_from_path_returns_tool_instance
456
+ tool = ScriptTool.from_path(FIXTURE_SCRIPT)
457
+ refute_nil tool
458
+ assert_respond_to tool, :name
459
+ end
460
+
461
+ def test_tool_name_derived_from_filename
462
+ tool = ScriptTool.from_path(FIXTURE_SCRIPT)
463
+ assert_equal "hello", tool.name
464
+ end
465
+
466
+ def test_tool_description_from_first_comment
467
+ tool = ScriptTool.from_path(FIXTURE_SCRIPT)
468
+ assert_equal "Say hello to the world", tool.description
469
+ end
470
+
471
+ def test_tool_execution_returns_script_output
472
+ tool = ScriptTool.from_path(FIXTURE_SCRIPT)
473
+ output = tool.call({})
474
+ assert_includes output, "Hello from AgentSkills script!"
475
+ end
476
+
477
+ def test_from_path_returns_nil_for_nonexecutable_script
478
+ Tempfile.create(["nonexec", ".sh"]) do |f|
479
+ f.write("#!/bin/bash\necho hello")
480
+ f.flush
481
+ FileUtils.chmod(0o644, f.path)
482
+ result = ScriptTool.from_path(f.path)
483
+ assert_nil result
484
+ end
485
+ end
486
+
487
+ def test_tool_name_with_hyphens_converted_to_underscores
488
+ Tempfile.create(["check-style", ".sh"]) do |f|
489
+ f.write("#!/bin/bash\necho ok")
490
+ f.flush
491
+ FileUtils.chmod(0o755, f.path)
492
+ tool = ScriptTool.from_path(f.path)
493
+ assert_equal "check_style", tool.name
494
+ end
495
+ end
496
+ end
497
+ end
498
+ ```
499
+
500
+ - [ ] **Step 2: Run to verify failure**
501
+
502
+ ```bash
503
+ bundle exec rake test_file[robot_lab/script_tool_test.rb]
504
+ ```
505
+ Expected: `NameError: uninitialized constant RobotLab::ScriptTool`
506
+
507
+ - [ ] **Step 3: Implement ScriptTool**
508
+
509
+ `lib/robot_lab/script_tool.rb`:
510
+ ```ruby
511
+ # frozen_string_literal: true
512
+
513
+ require "open3"
514
+ require "pathname"
515
+ require "tempfile"
516
+
517
+ module RobotLab
518
+ # Factory module for wrapping AgentSkills scripts as RobotLab::Tool instances.
519
+ #
520
+ # Given a path to an executable shell script, produces a Tool that shells
521
+ # out to the script and returns its combined stdout+stderr output.
522
+ #
523
+ # Non-executable scripts return nil with a logged warning.
524
+ module ScriptTool
525
+ # Wrap a script file as a RobotLab::Tool.
526
+ #
527
+ # @param script_path [String, Pathname] path to the script file
528
+ # @return [RobotLab::Tool, nil] nil if the script is not executable
529
+ def self.from_path(script_path)
530
+ path = Pathname.new(script_path)
531
+
532
+ unless path.executable?
533
+ RobotLab.config.logger.warn(
534
+ "ScriptTool: #{path.basename} is not executable, skipping"
535
+ )
536
+ return nil
537
+ end
538
+
539
+ tool_name = derive_name(path)
540
+ description = extract_description(path)
541
+ script = path.to_s
542
+
543
+ Tool.create(
544
+ name: tool_name,
545
+ description: description,
546
+ parameters: {
547
+ type: "object",
548
+ properties: {
549
+ args: { type: "string", description: "Optional command-line arguments" }
550
+ },
551
+ required: []
552
+ }
553
+ ) do |tool_args|
554
+ cli_args = tool_args[:args].to_s.strip
555
+ cmd = cli_args.empty? ? ["bash", script] : ["bash", script, *cli_args.split]
556
+ output, status = Open3.capture2e(*cmd)
557
+ status.success? ? output : "Error (exit #{status.exitstatus}):\n#{output}"
558
+ end
559
+ end
560
+
561
+ # @param path [Pathname]
562
+ # @return [String] snake_case tool name from filename
563
+ def self.derive_name(path)
564
+ path.basename.to_s
565
+ .sub(/\.[^.]+$/, "")
566
+ .gsub(/[^a-zA-Z0-9]+/, "_")
567
+ .gsub(/^_+|_+$/, "")
568
+ end
569
+
570
+ # Extract the tool description from the first comment line of the script.
571
+ #
572
+ # @param path [Pathname]
573
+ # @return [String]
574
+ def self.extract_description(path)
575
+ first_comment = File.foreach(path).find { |line| line.strip.start_with?("#") }
576
+ if first_comment
577
+ first_comment.strip.sub(/^#+!.*$/, "").sub(/^#+\s*/, "").strip
578
+ else
579
+ derive_name(path)
580
+ end
581
+ rescue
582
+ derive_name(path)
583
+ end
584
+ end
585
+ end
586
+ ```
587
+
588
+ > **Note on description extraction:** The first comment line of `hello.sh` is `#!/usr/bin/env bash` (a shebang). The regex `sub(/^#+!.*$/, "")` strips shebang lines, so the method moves to the next comment: `# Say hello to the world`. Adjust `extract_description` if your scripts don't follow this pattern.
589
+
590
+ Wait — `File.foreach` returns the first line that matches, which will be the shebang `#!/usr/bin/env bash`. The shebang regex strips it to `""`. An empty string is falsey... actually `"".strip` is `""` which is truthy in Ruby. Let me fix this:
591
+
592
+ Replace the `extract_description` implementation with one that skips shebangs:
593
+
594
+ ```ruby
595
+ def self.extract_description(path)
596
+ File.foreach(path) do |line|
597
+ stripped = line.strip
598
+ next unless stripped.start_with?("#")
599
+ next if stripped.start_with?("#!") # skip shebang
600
+ desc = stripped.sub(/^#+\s*/, "").strip
601
+ return desc unless desc.empty?
602
+ end
603
+ derive_name(path)
604
+ rescue
605
+ derive_name(path)
606
+ end
607
+ ```
608
+
609
+ Use this corrected version in the file.
610
+
611
+ - [ ] **Step 4: Run tests to verify they pass**
612
+
613
+ ```bash
614
+ bundle exec rake test_file[robot_lab/script_tool_test.rb]
615
+ ```
616
+ Expected: 6 tests pass.
617
+
618
+ - [ ] **Step 5: Now re-run AgentSkill tests (they depend on ScriptTool)**
619
+
620
+ ```bash
621
+ bundle exec rake test_file[robot_lab/agent_skill_test.rb]
622
+ ```
623
+ Expected: All 8 tests pass.
624
+
625
+ - [ ] **Step 6: Commit**
626
+
627
+ ```bash
628
+ git add lib/robot_lab/script_tool.rb test/robot_lab/script_tool_test.rb
629
+ git commit -m "feat(script_tool): add ScriptTool factory for wrapping scripts as tools"
630
+ ```
631
+
632
+ ---
633
+
634
+ ## Task 5: Modify expand_skills for AgentSkill detection
635
+
636
+ **Files:**
637
+ - Modify: `lib/robot_lab/robot/template_rendering.rb`
638
+
639
+ - [ ] **Step 1: Write a failing test for the new expand_skills behavior**
640
+
641
+ Add this test to `test/robot_lab/robot_test.rb` (find the existing test class and add inside it):
642
+
643
+ ```ruby
644
+ def test_expand_skills_stores_agent_skill_in_pending_when_catalog_hit
645
+ # Point catalog at our fixture directory
646
+ fixtures = File.expand_path("../fixtures/skills", __dir__)
647
+ catalog = RobotLab::AgentSkillCatalog.new(fixtures)
648
+
649
+ robot = build_robot(name: "bot", skills: [:test_skill])
650
+ robot.instance_variable_set(:@pending_agent_skills, [])
651
+ robot.instance_variable_set(:@agent_skill_store, RobotLab::DocumentStore.new)
652
+
653
+ robot.send(:expand_skills_with_catalog, [:test_skill], Set.new, catalog)
654
+
655
+ pending = robot.instance_variable_get(:@pending_agent_skills)
656
+ assert_equal 1, pending.length
657
+ assert_equal "test_skill", pending.first.name
658
+ end
659
+
660
+ def test_expand_skills_uses_pm_template_when_not_in_catalog
661
+ # :assistant is a PM template in examples/prompts/
662
+ robot = build_robot(name: "bot")
663
+ result = robot.send(:expand_skills, [:assistant], Set.new)
664
+ assert_includes result, :assistant
665
+ end
666
+ ```
667
+
668
+ - [ ] **Step 2: Run to verify failure**
669
+
670
+ ```bash
671
+ bundle exec rake test_file[robot_lab/robot_test.rb]
672
+ ```
673
+ Expected: `NoMethodError: undefined method 'expand_skills_with_catalog'`
674
+
675
+ - [ ] **Step 3: Modify expand_skills in template_rendering.rb**
676
+
677
+ Open `lib/robot_lab/robot/template_rendering.rb`. Find `expand_skills` (line ~153) and replace its body:
678
+
679
+ ```ruby
680
+ def expand_skills(skill_ids, visited = Set.new)
681
+ expand_skills_with_catalog(skill_ids, visited, AgentSkillCatalog.instance)
682
+ end
683
+
684
+ def expand_skills_with_catalog(skill_ids, visited, catalog)
685
+ result = []
686
+
687
+ skill_ids.each do |skill_id|
688
+ skill_id = skill_id.to_sym
689
+
690
+ if visited.include?(skill_id)
691
+ RobotLab.config.logger.warn(
692
+ "Robot '#{@name}': skill cycle detected at '#{skill_id}', skipping"
693
+ )
694
+ next
695
+ end
696
+
697
+ visited.add(skill_id)
698
+
699
+ # Check catalog first: AgentSkills folder format takes priority
700
+ if (agent_skill = catalog.find(skill_id))
701
+ @pending_agent_skills ||= []
702
+ @agent_skill_store ||= DocumentStore.new
703
+ @pending_agent_skills << agent_skill
704
+ @agent_skill_store.store(agent_skill.name.to_sym, agent_skill.description)
705
+ next
706
+ end
707
+
708
+ # Fall back to PM template (existing behavior)
709
+ parsed = PM.parse(skill_id)
710
+ nested = extract_skills_from_metadata(parsed.metadata)
711
+
712
+ expand_skills_with_catalog(nested, visited, catalog).tap do |nested_result|
713
+ result.concat(nested_result)
714
+ end
715
+
716
+ result << skill_id
717
+ end
718
+
719
+ result
720
+ end
721
+ ```
722
+
723
+ - [ ] **Step 4: Run tests to verify they pass**
724
+
725
+ ```bash
726
+ bundle exec rake test_file[robot_lab/robot_test.rb]
727
+ ```
728
+ Expected: New tests pass; no regressions.
729
+
730
+ - [ ] **Step 5: Run the full test suite to check for regressions**
731
+
732
+ ```bash
733
+ bundle exec rake test
734
+ ```
735
+ Expected: All tests pass.
736
+
737
+ - [ ] **Step 6: Commit**
738
+
739
+ ```bash
740
+ git add lib/robot_lab/robot/template_rendering.rb test/robot_lab/robot_test.rb
741
+ git commit -m "feat(template_rendering): detect AgentSkills folder format in expand_skills"
742
+ ```
743
+
744
+ ---
745
+
746
+ ## Task 6: Robot constructor — initialize pending skill state
747
+
748
+ **Files:**
749
+ - Modify: `lib/robot_lab/robot.rb`
750
+
751
+ - [ ] **Step 1: Add `@pending_agent_skills` and `@agent_skill_store` to Robot#initialize**
752
+
753
+ In `lib/robot_lab/robot.rb`, find the line:
754
+ ```ruby
755
+ @skills = skills ? Array(skills).map(&:to_sym) : nil
756
+ @expanded_skills = nil
757
+ ```
758
+
759
+ Add immediately after:
760
+ ```ruby
761
+ @pending_agent_skills = []
762
+ @agent_skill_store = nil
763
+ ```
764
+
765
+ - [ ] **Step 2: Add `require_relative` for agent_skill_matching**
766
+
767
+ In `lib/robot_lab/robot.rb`, find:
768
+ ```ruby
769
+ require_relative 'robot/template_rendering'
770
+ require_relative 'robot/mcp_management'
771
+ require_relative 'robot/bus_messaging'
772
+ require_relative 'robot/history_search'
773
+ ```
774
+
775
+ Add:
776
+ ```ruby
777
+ require_relative 'robot/agent_skill_matching'
778
+ ```
779
+
780
+ - [ ] **Step 3: Prepend AgentSkillMatching in the Robot class body**
781
+
782
+ In `lib/robot_lab/robot.rb`, find the `include` block:
783
+ ```ruby
784
+ include Robot::TemplateRendering
785
+ include Robot::MCPManagement
786
+ include Robot::BusMessaging
787
+ include Robot::HistorySearch
788
+ include Durable::Learning
789
+ ```
790
+
791
+ Add `prepend` after the `include` lines:
792
+ ```ruby
793
+ prepend Robot::AgentSkillMatching
794
+ ```
795
+
796
+ - [ ] **Step 4: Run the test suite to verify no regressions before adding the module**
797
+
798
+ ```bash
799
+ bundle exec rake test
800
+ ```
801
+ Expected: All tests pass (AgentSkillMatching module doesn't exist yet but `prepend` on a missing constant will fail — so create a stub file first in Step 5, then re-run).
802
+
803
+ - [ ] **Step 5: Create a stub AgentSkillMatching so requires resolve**
804
+
805
+ `lib/robot_lab/robot/agent_skill_matching.rb`:
806
+ ```ruby
807
+ # frozen_string_literal: true
808
+
809
+ module RobotLab
810
+ class Robot < RubyLLM::Agent
811
+ module AgentSkillMatching
812
+ # Implemented in Task 7
813
+ end
814
+ end
815
+ end
816
+ ```
817
+
818
+ ```bash
819
+ bundle exec rake test
820
+ ```
821
+ Expected: All existing tests pass.
822
+
823
+ - [ ] **Step 6: Commit**
824
+
825
+ ```bash
826
+ git add lib/robot_lab/robot.rb lib/robot_lab/robot/agent_skill_matching.rb
827
+ git commit -m "feat(robot): initialize pending_agent_skills state, prepend AgentSkillMatching"
828
+ ```
829
+
830
+ ---
831
+
832
+ ## Task 7: AgentSkillMatching mixin — runtime injection
833
+
834
+ **Files:**
835
+ - Modify: `lib/robot_lab/robot/agent_skill_matching.rb`
836
+ - Create: `test/robot_lab/robot/agent_skill_matching_test.rb`
837
+
838
+ - [ ] **Step 1: Write the failing tests**
839
+
840
+ `test/robot_lab/robot/agent_skill_matching_test.rb`:
841
+ ```ruby
842
+ # frozen_string_literal: true
843
+
844
+ require "test_helper"
845
+
846
+ module RobotLab
847
+ class Robot
848
+ class AgentSkillMatchingTest < Minitest::Test
849
+ FIXTURES = File.expand_path("../../../fixtures/skills", __dir__)
850
+
851
+ def skill_for(name)
852
+ path = File.join(FIXTURES, name.to_s, "SKILL.md")
853
+ AgentSkill.new(path)
854
+ end
855
+
856
+ def robot_with_pending_skills(*skill_names)
857
+ robot = build_robot(name: "test_bot")
858
+ skills = skill_names.map { |n| skill_for(n) }
859
+ store = DocumentStore.new
860
+ skills.each { |s| store.store(s.name.to_sym, s.description) }
861
+ robot.instance_variable_set(:@pending_agent_skills, skills)
862
+ robot.instance_variable_set(:@agent_skill_store, store)
863
+ robot
864
+ end
865
+
866
+ def test_match_returns_empty_when_no_pending_skills
867
+ robot = build_robot(name: "bot")
868
+ result = robot.send(:match_agent_skills, "any message")
869
+ assert_equal [], result
870
+ end
871
+
872
+ def test_match_returns_skills_above_threshold
873
+ robot = robot_with_pending_skills(:test_skill)
874
+ # Pass a message semantically similar to test_skill's description
875
+ # "A test skill for verifying AgentSkills.io integration"
876
+ result = robot.send(:match_agent_skills,
877
+ "I need to verify the AgentSkills integration works",
878
+ threshold: 0.3)
879
+ assert_equal 1, result.length
880
+ assert_equal "test_skill", result.first.name
881
+ end
882
+
883
+ def test_match_returns_empty_below_threshold
884
+ robot = robot_with_pending_skills(:test_skill)
885
+ result = robot.send(:match_agent_skills,
886
+ "I need to verify the AgentSkills integration works",
887
+ threshold: 0.999)
888
+ assert_equal [], result
889
+ end
890
+
891
+ def test_inject_prepends_instructions_to_system_prompt
892
+ robot = build_robot(name: "bot", system_prompt: "You are helpful.")
893
+ skill = skill_for(:test_skill)
894
+ robot.send(:inject_agent_skills, [skill])
895
+
896
+ instructions = system_instructions(robot)
897
+ assert instructions.start_with?(skill.instructions),
898
+ "Expected system prompt to start with skill instructions"
899
+ assert_includes instructions, "You are helpful."
900
+ end
901
+
902
+ def test_inject_adds_script_tools_to_local_tools
903
+ robot = build_robot(name: "bot")
904
+ skill = skill_for(:scripted_skill)
905
+ robot.send(:inject_agent_skills, [skill])
906
+
907
+ tool_names = robot.local_tools.map(&:name)
908
+ assert_includes tool_names, "hello"
909
+ end
910
+
911
+ def test_restore_removes_injected_tools
912
+ robot = build_robot(name: "bot")
913
+ skill = skill_for(:scripted_skill)
914
+ original_count = robot.local_tools.length
915
+
916
+ robot.send(:inject_agent_skills, [skill])
917
+ robot.send(:restore_after_agent_skills)
918
+
919
+ assert_equal original_count, robot.local_tools.length
920
+ end
921
+
922
+ def test_restore_restores_original_system_prompt
923
+ robot = build_robot(name: "bot", system_prompt: "You are helpful.")
924
+ skill = skill_for(:test_skill)
925
+
926
+ robot.send(:inject_agent_skills, [skill])
927
+ robot.send(:restore_after_agent_skills)
928
+
929
+ assert_equal "You are helpful.", system_instructions(robot)
930
+ end
931
+
932
+ def test_match_degrades_gracefully_on_embedding_failure
933
+ robot = build_robot(name: "bot")
934
+ broken_store = Object.new
935
+ def broken_store.search(*) = raise "fastembed failed"
936
+ robot.instance_variable_set(:@pending_agent_skills, [skill_for(:test_skill)])
937
+ robot.instance_variable_set(:@agent_skill_store, broken_store)
938
+
939
+ result = robot.send(:match_agent_skills, "anything")
940
+ assert_equal [], result
941
+ end
942
+ end
943
+ end
944
+ end
945
+ ```
946
+
947
+ - [ ] **Step 2: Run to verify failure**
948
+
949
+ ```bash
950
+ bundle exec rake test_file[robot_lab/robot/agent_skill_matching_test.rb]
951
+ ```
952
+ Expected: Tests fail with `NoMethodError` on `match_agent_skills`.
953
+
954
+ - [ ] **Step 3: Implement AgentSkillMatching**
955
+
956
+ Replace the stub in `lib/robot_lab/robot/agent_skill_matching.rb` with the full implementation:
957
+
958
+ ```ruby
959
+ # frozen_string_literal: true
960
+
961
+ module RobotLab
962
+ class Robot < RubyLLM::Agent
963
+ # Prepended module that intercepts run() to inject relevant AgentSkills.io
964
+ # skills into the system prompt and tool list for the duration of each call.
965
+ #
966
+ # Skills are matched by embedding similarity between the incoming message
967
+ # and each pending skill's description (via DocumentStore/fastembed).
968
+ # Injected content is fully restored in an ensure block after run() returns.
969
+ module AgentSkillMatching
970
+ # Default cosine similarity threshold for skill activation.
971
+ SIMILARITY_THRESHOLD = 0.70
972
+
973
+ def run(message = nil, **kwargs, &block)
974
+ activated = match_agent_skills(message.to_s)
975
+ inject_agent_skills(activated) if activated.any?
976
+ super(message, **kwargs, &block)
977
+ ensure
978
+ restore_after_agent_skills if activated&.any?
979
+ end
980
+
981
+ # Override to re-inject skill instructions after template re-render.
982
+ # rerender_template replaces the system prompt; re-prepend skills after.
983
+ def rerender_template(run_context)
984
+ super
985
+ return unless @_agent_skill_injected_tools # nil when no skills active
986
+
987
+ @_agent_skill_original_instructions = current_agent_skill_instructions
988
+ prepend_skill_instructions(@_agent_skill_active_skills)
989
+ end
990
+
991
+ private
992
+
993
+ # Find pending AgentSkills whose descriptions match the message.
994
+ #
995
+ # @param message [String] the incoming user message
996
+ # @param threshold [Float] cosine similarity cutoff
997
+ # @return [Array<AgentSkill>]
998
+ def match_agent_skills(message, threshold: SIMILARITY_THRESHOLD)
999
+ return [] if @pending_agent_skills.nil? || @pending_agent_skills.empty?
1000
+
1001
+ results = @agent_skill_store.search(message, limit: @pending_agent_skills.size)
1002
+ results
1003
+ .select { |r| r[:score] >= threshold }
1004
+ .filter_map { |r| @pending_agent_skills.find { |s| s.name.to_sym == r[:key] } }
1005
+ rescue => e
1006
+ RobotLab.config.logger.warn(
1007
+ "Robot '#{@name}': AgentSkill embedding failed: #{e.message}"
1008
+ )
1009
+ []
1010
+ end
1011
+
1012
+ # Prepend skill instructions to system prompt and inject script tools.
1013
+ #
1014
+ # @param skills [Array<AgentSkill>]
1015
+ def inject_agent_skills(skills)
1016
+ @_agent_skill_active_skills = skills
1017
+ @_agent_skill_original_instructions = current_agent_skill_instructions
1018
+ prepend_skill_instructions(skills)
1019
+ @_agent_skill_injected_tools = skills.flat_map(&:script_tools).compact
1020
+ @local_tools = @local_tools + @_agent_skill_injected_tools
1021
+ end
1022
+
1023
+ # Restore system prompt and tool list to pre-injection state.
1024
+ def restore_after_agent_skills
1025
+ @local_tools = @local_tools - (@_agent_skill_injected_tools || [])
1026
+ @chat.with_instructions(@_agent_skill_original_instructions.to_s)
1027
+ @_agent_skill_active_skills = nil
1028
+ @_agent_skill_original_instructions = nil
1029
+ @_agent_skill_injected_tools = nil
1030
+ end
1031
+
1032
+ # Read the current system message content from the chat.
1033
+ #
1034
+ # @return [String, nil]
1035
+ def current_agent_skill_instructions
1036
+ messages = @chat.instance_variable_get(:@messages)
1037
+ sys = messages&.find { |m| m.role.to_s == "system" }
1038
+ sys&.content
1039
+ end
1040
+
1041
+ # Prepend all skill instruction bodies before existing system prompt.
1042
+ #
1043
+ # @param skills [Array<AgentSkill>]
1044
+ def prepend_skill_instructions(skills)
1045
+ skill_content = skills.map(&:instructions).join("\n\n")
1046
+ base = @_agent_skill_original_instructions.to_s
1047
+ combined = [skill_content, base].reject(&:empty?).join("\n\n")
1048
+ @chat.with_instructions(combined)
1049
+ end
1050
+ end
1051
+ end
1052
+ end
1053
+ ```
1054
+
1055
+ - [ ] **Step 4: Run the AgentSkillMatching tests**
1056
+
1057
+ ```bash
1058
+ bundle exec rake test_file[robot_lab/robot/agent_skill_matching_test.rb]
1059
+ ```
1060
+ Expected: All tests pass. (The embedding tests require fastembed to download the model on first run — this is expected and cached locally after that.)
1061
+
1062
+ - [ ] **Step 5: Run the full test suite**
1063
+
1064
+ ```bash
1065
+ bundle exec rake test
1066
+ ```
1067
+ Expected: All tests pass.
1068
+
1069
+ - [ ] **Step 6: Commit**
1070
+
1071
+ ```bash
1072
+ git add lib/robot_lab/robot/agent_skill_matching.rb \
1073
+ test/robot_lab/robot/agent_skill_matching_test.rb
1074
+ git commit -m "feat(agent_skill_matching): implement runtime embedding-based skill injection"
1075
+ ```
1076
+
1077
+ ---
1078
+
1079
+ ## Task 8: Integration test — mixed skills: param
1080
+
1081
+ **Files:**
1082
+ - Modify: `test/robot_lab/robot_test.rb`
1083
+
1084
+ - [ ] **Step 1: Write the integration test**
1085
+
1086
+ Find the existing `robot_test.rb` and add:
1087
+
1088
+ ```ruby
1089
+ def test_skills_param_handles_mixed_pm_and_agentskill_formats
1090
+ # :assistant is a PM template; :test_skill is an AgentSkill folder
1091
+ fixtures = File.expand_path("../fixtures/skills", __dir__)
1092
+ catalog = RobotLab::AgentSkillCatalog.new(fixtures)
1093
+
1094
+ robot = build_robot(name: "bot", skills: [:assistant])
1095
+ robot.instance_variable_set(:@pending_agent_skills, [])
1096
+ robot.instance_variable_set(:@agent_skill_store, RobotLab::DocumentStore.new)
1097
+
1098
+ skill = RobotLab::AgentSkill.new(File.join(fixtures, "test_skill", "SKILL.md"))
1099
+ robot.instance_variable_get(:@pending_agent_skills) << skill
1100
+ store = robot.instance_variable_get(:@agent_skill_store)
1101
+ store.store(skill.name.to_sym, skill.description)
1102
+
1103
+ # PM skills are in @expanded_skills; AgentSkills are in @pending_agent_skills
1104
+ expanded = robot.instance_variable_get(:@expanded_skills)
1105
+ pending = robot.instance_variable_get(:@pending_agent_skills)
1106
+
1107
+ assert_includes expanded, :assistant if expanded
1108
+ assert_equal 1, pending.length
1109
+ assert_equal "test_skill", pending.first.name
1110
+ end
1111
+
1112
+ def test_agentskill_script_tools_not_present_after_run_without_match
1113
+ fixtures = File.expand_path("../fixtures/skills", __dir__)
1114
+ skill = RobotLab::AgentSkill.new(File.join(fixtures, "scripted_skill", "SKILL.md"))
1115
+
1116
+ robot = build_robot(name: "bot", system_prompt: "You are helpful.")
1117
+ robot.instance_variable_set(:@pending_agent_skills, [skill])
1118
+ store = RobotLab::DocumentStore.new
1119
+ store.store(skill.name.to_sym, skill.description)
1120
+ robot.instance_variable_set(:@agent_skill_store, store)
1121
+
1122
+ initial_tool_count = robot.local_tools.length
1123
+
1124
+ # Use threshold 0.999 so no skill matches
1125
+ robot.stub(:match_agent_skills, []) do
1126
+ # Can't call actual run() without API key; verify tool count unchanged
1127
+ robot.send(:inject_agent_skills, [])
1128
+ robot.send(:restore_after_agent_skills)
1129
+ end
1130
+
1131
+ assert_equal initial_tool_count, robot.local_tools.length
1132
+ end
1133
+ ```
1134
+
1135
+ - [ ] **Step 2: Run the integration tests**
1136
+
1137
+ ```bash
1138
+ bundle exec rake test_file[robot_lab/robot_test.rb]
1139
+ ```
1140
+ Expected: All tests pass.
1141
+
1142
+ - [ ] **Step 3: Run the full test suite**
1143
+
1144
+ ```bash
1145
+ bundle exec rake test
1146
+ ```
1147
+ Expected: All tests pass.
1148
+
1149
+ - [ ] **Step 4: Commit**
1150
+
1151
+ ```bash
1152
+ git add test/robot_lab/robot_test.rb
1153
+ git commit -m "test(integration): verify mixed PM + AgentSkill skills: param behavior"
1154
+ ```
1155
+
1156
+ ---
1157
+
1158
+ ## Task 9: Example file
1159
+
1160
+ **Files:**
1161
+ - Create: `examples/34_agentskills.rb`
1162
+ - Create: `~/.prompts/skills/code_reviewer/SKILL.md`
1163
+
1164
+ - [ ] **Step 1: Create a demonstration AgentSkill in the user's skills directory**
1165
+
1166
+ ```bash
1167
+ mkdir -p ~/.prompts/skills/code_reviewer/scripts
1168
+ ```
1169
+
1170
+ `~/.prompts/skills/code_reviewer/SKILL.md`:
1171
+ ```markdown
1172
+ ---
1173
+ name: code_reviewer
1174
+ description: Review Ruby code for quality, style, and potential bugs
1175
+ ---
1176
+ When reviewing Ruby code, check for:
1177
+ - Frozen string literal comments at the top of files
1178
+ - Methods exceeding 20 lines — suggest splitting
1179
+ - Inline rescue usage — recommend dedicated rescue blocks
1180
+ - Missing nil guards on external data
1181
+ - Test coverage gaps for edge cases
1182
+
1183
+ Provide structured feedback with severity: info, warning, or error.
1184
+ ```
1185
+
1186
+ - [ ] **Step 2: Create the example script**
1187
+
1188
+ `examples/34_agentskills.rb`:
1189
+ ```ruby
1190
+ #!/usr/bin/env ruby
1191
+ # frozen_string_literal: true
1192
+
1193
+ # Example 34: AgentSkills.io Integration
1194
+ #
1195
+ # Demonstrates the unified skills: param detecting AgentSkills folder format.
1196
+ # Skills in ~/.prompts/skills/ are matched at runtime via embedding similarity
1197
+ # before each run() call — only relevant skills are injected.
1198
+ #
1199
+ # Usage:
1200
+ # mkdir -p ~/.prompts/skills/code_reviewer
1201
+ # # (create SKILL.md as shown in the example header)
1202
+ # ANTHROPIC_API_KEY=your_key ruby examples/34_agentskills.rb
1203
+
1204
+ ENV['ROBOT_LAB_TEMPLATE_PATH'] ||= File.join(__dir__, "prompts")
1205
+
1206
+ require_relative "../lib/robot_lab"
1207
+
1208
+ require "logger"
1209
+ log_file = File.join(__dir__, "34.log")
1210
+ RobotLab.config.logger = Logger.new(log_file)
1211
+ RubyLLM.configure { |c| c.logger = Logger.new(log_file) }
1212
+
1213
+ puts "=" * 60
1214
+ puts "RobotLab — AgentSkills.io Integration Demo"
1215
+ puts "=" * 60
1216
+ puts
1217
+
1218
+ # Check if the skill is installed
1219
+ skill_path = File.expand_path("~/.prompts/skills/code_reviewer/SKILL.md")
1220
+ unless File.exist?(skill_path)
1221
+ puts "Demo skill not found at #{skill_path}"
1222
+ puts "Create it with:"
1223
+ puts " mkdir -p ~/.prompts/skills/code_reviewer"
1224
+ puts " # Then add SKILL.md with name: code_reviewer"
1225
+ exit 1
1226
+ end
1227
+
1228
+ # Build a robot that lists code_reviewer as a candidate skill.
1229
+ # At runtime, if the user message is semantically similar to
1230
+ # "Review Ruby code for quality, style, and potential bugs",
1231
+ # the skill's instructions are injected into the system prompt.
1232
+ robot = RobotLab.build(
1233
+ name: "assistant",
1234
+ system_prompt: "You are a helpful Ruby programming assistant.",
1235
+ skills: [:code_reviewer]
1236
+ )
1237
+
1238
+ puts "Pending AgentSkills: #{robot.instance_variable_get(:@pending_agent_skills).map(&:name).inspect}"
1239
+ puts
1240
+
1241
+ # Message semantically related to code review — skill should activate
1242
+ code_question = <<~MSG
1243
+ Please review this Ruby method for quality issues:
1244
+
1245
+ def process(data)
1246
+ begin
1247
+ result = data.map { |item| transform(item) }
1248
+ save(result)
1249
+ rescue => e
1250
+ puts e.message
1251
+ end
1252
+ end
1253
+ MSG
1254
+
1255
+ puts "Query: code review (skill should activate)"
1256
+ result = robot.run(code_question)
1257
+ puts result.reply
1258
+ puts
1259
+ puts "=" * 60
1260
+
1261
+ # Message unrelated to code review — skill should NOT activate
1262
+ puts "Query: general question (skill should NOT activate)"
1263
+ result = robot.run("What is the capital of France?")
1264
+ puts result.reply
1265
+ ```
1266
+
1267
+ - [ ] **Step 3: Verify the example syntax**
1268
+
1269
+ ```bash
1270
+ ruby -c examples/34_agentskills.rb
1271
+ ```
1272
+ Expected: `Syntax OK`
1273
+
1274
+ - [ ] **Step 4: Commit**
1275
+
1276
+ ```bash
1277
+ git add examples/34_agentskills.rb
1278
+ git commit -m "feat(examples): add example 34 demonstrating AgentSkills.io integration"
1279
+ ```
1280
+
1281
+ ---
1282
+
1283
+ ## Self-Review Checklist
1284
+
1285
+ **Spec coverage:**
1286
+ - [x] Discovery path `~/.prompts/skills/` — `AgentSkillCatalog::SKILLS_ROOT`
1287
+ - [x] Unified `skills:` param — `expand_skills` detects catalog before PM
1288
+ - [x] SKILL.md parsing (name, description, body) — `AgentSkill#initialize`
1289
+ - [x] Scripts auto-wrapped as tools — `AgentSkill#script_tools` + `ScriptTool`
1290
+ - [x] Runtime embedding match — `AgentSkillMatching#match_agent_skills`
1291
+ - [x] Threshold 0.70 — `SIMILARITY_THRESHOLD` constant
1292
+ - [x] Injection + restore — `inject_agent_skills` / `restore_after_agent_skills`
1293
+ - [x] `rerender_template` re-injection — override in `AgentSkillMatching`
1294
+ - [x] Graceful degradation on embedding failure — rescue in `match_agent_skills`
1295
+ - [x] `ConfigurationError` on missing name/description — `AgentSkill#initialize`
1296
+ - [x] Non-executable script skipped — `ScriptTool.from_path`
1297
+ - [x] Cycle detection via visited Set — inherited in `expand_skills_with_catalog`
1298
+
1299
+ **Type consistency:**
1300
+ - `AgentSkill#script_tools` → `Array<RobotLab::Tool>` — matches `@local_tools` element type ✓
1301
+ - `AgentSkillCatalog#find` → `AgentSkill, nil` — used correctly in `expand_skills_with_catalog` ✓
1302
+ - `ScriptTool.from_path` → `RobotLab::Tool, nil` — `filter_map` in `script_tools` handles nil ✓
1303
+ - `match_agent_skills` → `Array<AgentSkill>` — passed to `inject_agent_skills` which calls `flat_map(&:script_tools)` ✓