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.
- checksums.yaml +4 -4
- data/.architecture/AGENTS.md +32 -0
- data/.architecture/config.yml +8 -0
- data/.architecture/members.yml +60 -0
- data/.architecture/reviews/feature-free-will.md +490 -0
- data/.architecture/reviews/overall-codebase.md +427 -0
- data/.claude/settings.local.json +57 -0
- data/.codex/config.toml +2 -0
- data/.irbrc +2 -2
- data/.rubocop.yml +172 -0
- data/CHANGELOG.md +72 -0
- data/CLAUDE.md +139 -0
- data/README.md +91 -95
- data/Rakefile +109 -3
- data/agent2agent_review.md +192 -0
- data/agentf_improvements.md +253 -0
- data/agents.md +14 -0
- data/docs/examples/index.md +37 -2
- data/docs/getting-started/configuration.md +20 -7
- data/docs/guides/index.md +16 -16
- data/docs/guides/knowledge.md +7 -1
- data/docs/guides/observability.md +132 -0
- data/docs/index.md +30 -3
- data/docs/superpowers/plans/2026-05-06-agentskills.md +1303 -0
- data/docs/superpowers/specs/2026-05-06-agentskills-design.md +247 -0
- data/examples/.envrc +1 -0
- data/examples/01_simple_robot.rb +5 -9
- data/examples/02_tools.rb +5 -9
- data/examples/03_network.rb +8 -9
- data/examples/04_mcp.rb +21 -29
- data/examples/05_streaming.rb +12 -18
- data/examples/06_prompt_templates.rb +11 -19
- data/examples/07_network_memory.rb +16 -31
- data/examples/08_llm_config.rb +10 -22
- data/examples/09_chaining.rb +16 -27
- data/examples/10_memory.rb +12 -28
- data/examples/11_network_introspection.rb +15 -29
- data/examples/12_message_bus.rb +5 -12
- data/examples/13_spawn.rb +5 -10
- data/examples/14_rusty_circuit/.envrc +1 -0
- data/examples/14_rusty_circuit/comic.rb +2 -0
- data/examples/14_rusty_circuit/heckler.rb +1 -1
- data/examples/14_rusty_circuit/open_mic.rb +1 -3
- data/examples/14_rusty_circuit/scout.rb +2 -0
- data/examples/15_memory_network_and_bus/.envrc +1 -0
- data/examples/15_memory_network_and_bus/editorial_pipeline.rb +6 -3
- data/examples/15_memory_network_and_bus/linux_writer.rb +1 -1
- data/examples/15_memory_network_and_bus/output/combined_article.md +6 -6
- data/examples/15_memory_network_and_bus/output/final_article.md +6 -8
- data/examples/15_memory_network_and_bus/output/linux_draft.md +4 -2
- data/examples/15_memory_network_and_bus/output/mac_draft.md +3 -3
- data/examples/15_memory_network_and_bus/output/memory.json +6 -6
- data/examples/15_memory_network_and_bus/output/revision_1.md +10 -11
- data/examples/15_memory_network_and_bus/output/revision_2.md +6 -8
- data/examples/15_memory_network_and_bus/output/windows_draft.md +3 -3
- data/examples/16_writers_room/.envrc +1 -0
- data/examples/16_writers_room/writers_room.rb +2 -4
- data/examples/17_skills.rb +8 -17
- data/examples/18_rails/Gemfile +1 -0
- data/examples/19_token_tracking.rb +9 -15
- data/examples/20_circuit_breaker.rb +10 -19
- data/examples/21_learning_loop.rb +11 -20
- data/examples/22_context_compression.rb +6 -13
- data/examples/23_convergence.rb +6 -17
- data/examples/24_structured_delegation.rb +11 -15
- data/examples/25_history_search.rb +5 -12
- data/examples/26_document_store.rb +6 -13
- data/examples/27_incident_response/incident_response.rb +4 -5
- data/examples/28_mcp_discovery.rb +8 -11
- data/examples/29_ractor_tools.rb +4 -9
- data/examples/30_ractor_network.rb +10 -19
- data/examples/31_launch_assessment.rb +10 -23
- data/examples/32_newsletter_reader.rb +188 -0
- data/examples/33_stock_generator.rb +80 -0
- data/examples/33_stock_predictor.rb +306 -0
- data/examples/34_agentskills.rb +72 -0
- data/examples/README.md +1 -1
- data/examples/common.rb +76 -0
- data/examples/ruboruby.md +423 -0
- data/examples/temp.md +51 -0
- data/lib/robot_lab/agent_skill.rb +63 -0
- data/lib/robot_lab/agent_skill_catalog.rb +74 -0
- data/lib/robot_lab/ask_user.rb +2 -2
- data/lib/robot_lab/bus_poller.rb +12 -5
- data/lib/robot_lab/config.rb +1 -12
- data/lib/robot_lab/delegation_future.rb +1 -1
- data/lib/robot_lab/doom_loop_detector.rb +98 -0
- data/lib/robot_lab/history_compressor.rb +4 -10
- data/lib/robot_lab/mcp/client.rb +1 -2
- data/lib/robot_lab/mcp/connection_poller.rb +3 -3
- data/lib/robot_lab/mcp/server.rb +1 -1
- data/lib/robot_lab/mcp/server_discovery.rb +0 -2
- data/lib/robot_lab/memory.rb +32 -27
- data/lib/robot_lab/memory_change.rb +2 -2
- data/lib/robot_lab/message.rb +4 -4
- data/lib/robot_lab/network.rb +11 -6
- data/lib/robot_lab/robot/agent_skill_matching.rb +99 -0
- data/lib/robot_lab/robot/bus_messaging.rb +9 -27
- data/lib/robot_lab/robot/history_search.rb +4 -1
- data/lib/robot_lab/robot/mcp_management.rb +5 -11
- data/lib/robot_lab/robot/template_rendering.rb +60 -40
- data/lib/robot_lab/robot.rb +323 -206
- data/lib/robot_lab/robot_result.rb +6 -5
- data/lib/robot_lab/run_config.rb +5 -11
- data/lib/robot_lab/script_tool.rb +76 -0
- data/lib/robot_lab/state_proxy.rb +7 -5
- data/lib/robot_lab/tool.rb +3 -3
- data/lib/robot_lab/tool_config.rb +1 -1
- data/lib/robot_lab/tool_manifest.rb +5 -7
- data/lib/robot_lab/user_message.rb +2 -2
- data/lib/robot_lab/version.rb +1 -1
- data/lib/robot_lab/waiter.rb +1 -1
- data/lib/robot_lab.rb +41 -52
- data/logfile +8 -0
- data/mkdocs.yml +2 -3
- data/robot_concurrency.md +38 -0
- data/simple_acp_review.md +298 -0
- data/site/404.html +2300 -0
- data/site/api/core/index.html +2706 -0
- data/site/api/core/memory/index.html +3793 -0
- data/site/api/core/network/index.html +3500 -0
- data/site/api/core/robot/index.html +4566 -0
- data/site/api/core/state/index.html +3390 -0
- data/site/api/core/tool/index.html +3843 -0
- data/site/api/index.html +2635 -0
- data/site/api/mcp/client/index.html +3435 -0
- data/site/api/mcp/index.html +2783 -0
- data/site/api/mcp/server/index.html +3252 -0
- data/site/api/mcp/transports/index.html +3352 -0
- data/site/api/messages/index.html +2641 -0
- data/site/api/messages/text-message/index.html +3087 -0
- data/site/api/messages/tool-call-message/index.html +3159 -0
- data/site/api/messages/tool-result-message/index.html +3252 -0
- data/site/api/messages/user-message/index.html +3212 -0
- data/site/api/streaming/context/index.html +3282 -0
- data/site/api/streaming/events/index.html +3347 -0
- data/site/api/streaming/index.html +2738 -0
- data/site/architecture/core-concepts/index.html +3757 -0
- data/site/architecture/index.html +2797 -0
- data/site/architecture/message-flow/index.html +3238 -0
- data/site/architecture/network-orchestration/index.html +3433 -0
- data/site/architecture/robot-execution/index.html +3140 -0
- data/site/architecture/state-management/index.html +3498 -0
- data/site/assets/css/custom.css +56 -0
- data/site/assets/images/favicon.png +0 -0
- data/site/assets/images/robot_lab.jpg +0 -0
- data/site/assets/javascripts/bundle.79ae519e.min.js +16 -0
- data/site/assets/javascripts/bundle.79ae519e.min.js.map +7 -0
- data/site/assets/javascripts/lunr/min/lunr.ar.min.js +1 -0
- data/site/assets/javascripts/lunr/min/lunr.da.min.js +18 -0
- data/site/assets/javascripts/lunr/min/lunr.de.min.js +18 -0
- data/site/assets/javascripts/lunr/min/lunr.du.min.js +18 -0
- data/site/assets/javascripts/lunr/min/lunr.el.min.js +1 -0
- data/site/assets/javascripts/lunr/min/lunr.es.min.js +18 -0
- data/site/assets/javascripts/lunr/min/lunr.fi.min.js +18 -0
- data/site/assets/javascripts/lunr/min/lunr.fr.min.js +18 -0
- data/site/assets/javascripts/lunr/min/lunr.he.min.js +1 -0
- data/site/assets/javascripts/lunr/min/lunr.hi.min.js +1 -0
- data/site/assets/javascripts/lunr/min/lunr.hu.min.js +18 -0
- data/site/assets/javascripts/lunr/min/lunr.hy.min.js +1 -0
- data/site/assets/javascripts/lunr/min/lunr.it.min.js +18 -0
- data/site/assets/javascripts/lunr/min/lunr.ja.min.js +1 -0
- data/site/assets/javascripts/lunr/min/lunr.jp.min.js +1 -0
- data/site/assets/javascripts/lunr/min/lunr.kn.min.js +1 -0
- data/site/assets/javascripts/lunr/min/lunr.ko.min.js +1 -0
- data/site/assets/javascripts/lunr/min/lunr.multi.min.js +1 -0
- data/site/assets/javascripts/lunr/min/lunr.nl.min.js +18 -0
- data/site/assets/javascripts/lunr/min/lunr.no.min.js +18 -0
- data/site/assets/javascripts/lunr/min/lunr.pt.min.js +18 -0
- data/site/assets/javascripts/lunr/min/lunr.ro.min.js +18 -0
- data/site/assets/javascripts/lunr/min/lunr.ru.min.js +18 -0
- data/site/assets/javascripts/lunr/min/lunr.sa.min.js +1 -0
- data/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +1 -0
- data/site/assets/javascripts/lunr/min/lunr.sv.min.js +18 -0
- data/site/assets/javascripts/lunr/min/lunr.ta.min.js +1 -0
- data/site/assets/javascripts/lunr/min/lunr.te.min.js +1 -0
- data/site/assets/javascripts/lunr/min/lunr.th.min.js +1 -0
- data/site/assets/javascripts/lunr/min/lunr.tr.min.js +18 -0
- data/site/assets/javascripts/lunr/min/lunr.vi.min.js +1 -0
- data/site/assets/javascripts/lunr/min/lunr.zh.min.js +1 -0
- data/site/assets/javascripts/lunr/tinyseg.js +206 -0
- data/site/assets/javascripts/lunr/wordcut.js +6708 -0
- data/site/assets/javascripts/workers/search.2c215733.min.js +42 -0
- data/site/assets/javascripts/workers/search.2c215733.min.js.map +7 -0
- data/site/assets/stylesheets/main.484c7ddc.min.css +1 -0
- data/site/assets/stylesheets/main.484c7ddc.min.css.map +1 -0
- data/site/assets/stylesheets/palette.ab4e12ef.min.css +1 -0
- data/site/assets/stylesheets/palette.ab4e12ef.min.css.map +1 -0
- data/site/concepts/index.html +3455 -0
- data/site/examples/basic-chat/index.html +2880 -0
- data/site/examples/index.html +2907 -0
- data/site/examples/mcp-server/index.html +3018 -0
- data/site/examples/multi-robot-network/index.html +3131 -0
- data/site/examples/rails-application/index.html +3329 -0
- data/site/examples/tool-usage/index.html +3085 -0
- data/site/getting-started/configuration/index.html +3745 -0
- data/site/getting-started/index.html +2572 -0
- data/site/getting-started/installation/index.html +2981 -0
- data/site/getting-started/quick-start/index.html +2942 -0
- data/site/guides/building-robots/index.html +4290 -0
- data/site/guides/creating-networks/index.html +3858 -0
- data/site/guides/index.html +2586 -0
- data/site/guides/mcp-integration/index.html +3581 -0
- data/site/guides/memory/index.html +3586 -0
- data/site/guides/rails-integration/index.html +4019 -0
- data/site/guides/streaming/index.html +3157 -0
- data/site/guides/using-tools/index.html +3802 -0
- data/site/index.html +2671 -0
- data/site/search/search_index.json +1 -0
- data/site/sitemap.xml +183 -0
- data/site/sitemap.xml.gz +0 -0
- data/site/tags.json +1 -0
- data/temp.md +6 -0
- data/tool_manifest_plan.md +155 -0
- metadata +154 -92
- data/docs/examples/rails-application.md +0 -419
- data/docs/guides/ractor-parallelism.md +0 -364
- data/docs/guides/rails-integration.md +0 -681
- data/docs/superpowers/plans/2026-04-14-ractor-integration.md +0 -1538
- data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +0 -258
- data/lib/generators/robot_lab/install_generator.rb +0 -90
- data/lib/generators/robot_lab/job_generator.rb +0 -40
- data/lib/generators/robot_lab/robot_generator.rb +0 -55
- data/lib/generators/robot_lab/templates/initializer.rb.tt +0 -42
- data/lib/generators/robot_lab/templates/job.rb.tt +0 -21
- data/lib/generators/robot_lab/templates/migration.rb.tt +0 -32
- data/lib/generators/robot_lab/templates/result_model.rb.tt +0 -52
- data/lib/generators/robot_lab/templates/robot.rb.tt +0 -31
- data/lib/generators/robot_lab/templates/robot_job.rb.tt +0 -18
- data/lib/generators/robot_lab/templates/robot_test.rb.tt +0 -34
- data/lib/generators/robot_lab/templates/routing_robot.rb.tt +0 -59
- data/lib/generators/robot_lab/templates/thread_model.rb.tt +0 -40
- data/lib/robot_lab/document_store.rb +0 -155
- data/lib/robot_lab/ractor_boundary.rb +0 -42
- data/lib/robot_lab/ractor_job.rb +0 -37
- data/lib/robot_lab/ractor_memory_proxy.rb +0 -85
- data/lib/robot_lab/ractor_network_scheduler.rb +0 -154
- data/lib/robot_lab/ractor_worker_pool.rb +0 -117
- data/lib/robot_lab/rails_integration/engine.rb +0 -29
- data/lib/robot_lab/rails_integration/job.rb +0 -158
- data/lib/robot_lab/rails_integration/railtie.rb +0 -51
- 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)` ✓
|