Skip to content

Commit d7edf06

Browse files
committed
feat(skills): Implement assignable skills library and api support
- Added a new API for managing assignable skills, including endpoints to list categories and skills, and retrieve skill content. - Implemented functionality to copy selected skills from the global library to topic workspaces for moderator assignment. - Created a guide for managing skills as submodules, including import scripts and documentation for updating skill libraries. - Updated the main application to include the new skills router and integrate skills into the discussion flow. - Enhanced README and documentation to reflect the new skills features and usage instructions.
1 parent d275617 commit d7edf06

32 files changed

Lines changed: 2087 additions & 6 deletions
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
---
2+
name: skills-submodule-guide
3+
description: Guides how to update skills via submodule, add new skills submodules, and find skills in complex directory structures. Use when the user asks about updating imported skills, adding a new skill library, importing skill repos, or locating skills in assignable_skills.
4+
---
5+
6+
# Skills Submodule Guide
7+
8+
When working with assignable skills submodules in Agent Topic Lab, follow these procedures.
9+
10+
## 1. Update Existing Skill Library
11+
12+
When upstream (e.g. AI-Research-SKILLs, anthropics/skills) has new commits:
13+
14+
```bash
15+
# From backend root — script pulls submodule and updates meta
16+
./scripts/import_skill_repo.sh git@github.com:Orchestra-Research/AI-Research-SKILLs.git ai-research
17+
./scripts/import_skill_repo.sh git@github.com:anthropics/skills.git anthropics
18+
```
19+
20+
Restart backend after update.
21+
22+
## 2. Add New Skills Submodule
23+
24+
1. Ensure the repo contains `SKILL.md` files (various structures supported).
25+
2. Run:
26+
```bash
27+
./scripts/import_skill_repo.sh <repo_url> [source_name]
28+
```
29+
3. `source_name` is optional; derived from URL if omitted (e.g. `AI-Research-SKILLs``ai-research`).
30+
4. Commit: `_submodules/<source>/`, `assignable_skills/<source>/`, `meta.json`, `.gitmodules`.
31+
32+
**Examples**:
33+
- `./scripts/import_skill_repo.sh git@github.com:Orchestra-Research/AI-Research-SKILLs.git`
34+
- `./scripts/import_skill_repo.sh git@github.com:anthropics/skills.git anthropics`
35+
36+
## 3. Find Skills in Complex Directories
37+
38+
### Discovery Rule
39+
40+
The import script recursively finds all `SKILL.md`:
41+
42+
- **Skill dir** = directory containing `SKILL.md`
43+
- **slug** = skill dir name
44+
- **category** = parent dir name (or `general` if at repo root)
45+
46+
### Common Structures
47+
48+
| Pattern | Example | category | slug |
49+
|---------|---------|----------|------|
50+
| `{category}/{skill}/SKILL.md` | `01-model-architecture/litgpt/SKILL.md` | `01-model-architecture` | `litgpt` |
51+
| `skills/{skill}/SKILL.md` | `skills/theme-factory/SKILL.md` | `skills` | `theme-factory` |
52+
| `{skill}/SKILL.md` at root | `20-ml-paper-writing/SKILL.md` | `general` | `20-ml-paper-writing` |
53+
54+
### Search Commands
55+
56+
```bash
57+
# All SKILL.md (skills stay in _submodules; no symlinks)
58+
find skills/assignable_skills/_submodules -name "SKILL.md"
59+
60+
# By keyword in meta
61+
cat skills/assignable_skills/ai-research/meta.json | python3 -c "
62+
import json,sys
63+
d=json.load(sys.stdin)
64+
q='rag'
65+
for sid,s in d['skills'].items():
66+
if q in (s.get('name','')+s.get('description','')).lower():
67+
print(sid, s.get('name'))
68+
"
69+
```
70+
71+
### Path Mapping
72+
73+
- **Default**: `assignable_skills/{source}/{category}/{slug}.md`
74+
- **Imported** (submodule): `assignable_skills/_submodules/{source}/{skills_dir}/{category}/{slug}/SKILL.md` — no symlinks; `skills_dir` in meta (e.g. `"."` for ai-research, `"skills"` for anthropics)
75+
- Skill ID: `{source}:{slug}` (e.g. `ai-research:litgpt`). Default source has no prefix.
76+
77+
## Quick Reference
78+
79+
| Action | Command |
80+
|--------|---------|
81+
| Update/add skill library | `./scripts/import_skill_repo.sh <url> [source]` |
82+
| List sources | `cat skills/assignable_skills/meta.json` |
83+
| List skills by source | `cat skills/assignable_skills/<source>/meta.json` |
84+
| Find SKILL.md | `find skills/assignable_skills/_submodules -name "SKILL.md"` |
85+
86+
## Related Docs
87+
88+
- [docs/skills-submodule-guide.md](../../../docs/skills-submodule-guide.md) — Brief overview, points to this skill
89+
- [docs/import-skill-repo.md](../../../docs/import-skill-repo.md) — Import script quick reference

.gitmodules

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[submodule "skills/assignable_skills/_submodules/ai-research"]
2+
path = skills/assignable_skills/_submodules/ai-research
3+
url = https://github.com/Orchestra-Research/AI-Research-SKILLs.git
4+
[submodule "skills/assignable_skills/_submodules/anthropics"]
5+
path = skills/assignable_skills/_submodules/anthropics
6+
url = https://github.com/anthropics/skills.git

README.en.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,9 @@ Or use `docker compose up --build`.
200200
| [docs/config.md](docs/config.md) | Env config |
201201
| [docs/testing.md](docs/testing.md) | Testing guide |
202202
| [docs/api-reference.md](docs/api-reference.md) | API reference |
203+
| [docs/assignable-skills-flow.md](docs/assignable-skills-flow.md) | Assignable skills API and copy logic |
204+
| [docs/skills-submodule-guide.md](docs/skills-submodule-guide.md) | Add/update skill libraries via submodule |
205+
| [docs/import-skill-repo.md](docs/import-skill-repo.md) | One-click import script for external skill repos |
203206
| [docs/troubleshooting.md](docs/troubleshooting.md) | Troubleshooting (dependency install, etc.) |
204207

205208
## Pragmatic Roadmap
@@ -213,7 +216,8 @@ Or use `docker compose up --build`.
213216
- `GET /health` — Health check
214217
- **Topics**: `GET/POST /topics`, `GET/PATCH /topics/{topic_id}`, `POST /topics/{topic_id}/close`
215218
- **Posts**: `GET/POST /topics/{topic_id}/posts`, `POST .../posts/mention`, `GET .../mention/{reply_post_id}`
216-
- **Discussion**: `POST /topics/{topic_id}/discussion`, `GET .../discussion/status`
219+
- **Discussion**: `POST /topics/{topic_id}/discussion` (supports `skill_list`), `GET .../discussion/status`
220+
- **Assignable Skills**: `GET /skills/assignable/categories`, `GET /skills/assignable`, `GET /skills/assignable/{skill_id}/content`
217221
- **Topic Experts**: `GET/POST /topics/{topic_id}/experts`, `PUT/DELETE .../experts/{expert_name}`, `POST .../experts/generate`
218222
- **Moderator Modes**: `GET /moderator-modes`, `GET/PUT /topics/{topic_id}/moderator-mode`, `POST .../moderator-mode/generate`
219223
- **Experts**: `GET /experts`, `GET/PUT /experts/{name}`

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,9 @@ docker run --rm -p 8000:8000 --env-file .env \
202202
| [docs/config.md](docs/config.md) | 环境变量配置 |
203203
| [docs/testing.md](docs/testing.md) | 测试指南 |
204204
| [docs/api-reference.md](docs/api-reference.md) | API 参考 |
205+
| [docs/assignable-skills-flow.md](docs/assignable-skills-flow.md) | 可分配技能 API 与复制逻辑 |
206+
| [docs/skills-submodule-guide.md](docs/skills-submodule-guide.md) | 技能库 submodule 增改指南(含 Cursor skill 入口) |
207+
| [docs/import-skill-repo.md](docs/import-skill-repo.md) | 一键导入外部技能库脚本 |
205208
| [docs/troubleshooting.md](docs/troubleshooting.md) | Troubleshooting (dependency install, etc.) |
206209

207210
## 务实发展方向
@@ -215,7 +218,8 @@ docker run --rm -p 8000:8000 --env-file .env \
215218
- `GET /health` — 健康检查
216219
- **Topics**`GET/POST /topics``GET/PATCH /topics/{topic_id}``POST /topics/{topic_id}/close`
217220
- **Posts**`GET/POST /topics/{topic_id}/posts``POST .../posts/mention``GET .../mention/{reply_post_id}`
218-
- **Discussion**`POST /topics/{topic_id}/discussion``GET .../discussion/status`
221+
- **Discussion**`POST /topics/{topic_id}/discussion`(支持 `skill_list`),`GET .../discussion/status`
222+
- **Assignable Skills**`GET /skills/assignable/categories``GET /skills/assignable``GET /skills/assignable/{skill_id}/content`
219223
- **Topic Experts**`GET/POST /topics/{topic_id}/experts``PUT/DELETE .../experts/{expert_name}``POST .../experts/generate`
220224
- **Moderator Modes**`GET /moderator-modes``GET/PUT /topics/{topic_id}/moderator-mode``POST .../moderator-mode/generate`
221225
- **Experts**`GET /experts``GET/PUT /experts/{name}`

app/agent/discussion.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from .moderator_modes import get_moderator_prompt, prepare_moderator_skill
1919
from .topic_sandbox import exclusive_topic_sandbox
2020
from .workspace import (
21+
copy_skills_to_workspace,
2122
ensure_topic_workspace,
2223
init_discussion_history,
2324
read_discussion_history,
@@ -135,6 +136,7 @@ async def run_discussion_for_topic(
135136
max_budget_usd: float = 5.0,
136137
model: str | None = None,
137138
allowed_tools: list[str] | None = None,
139+
skill_list: list[str] | None = None,
138140
) -> dict[str, Any]:
139141
"""Run discussion for a topic; return discussion_history, summary, cost, etc."""
140142
from app.core.config import get_workspace_base
@@ -143,6 +145,12 @@ async def run_discussion_for_topic(
143145
ws_path = ensure_topic_workspace(base, topic_id)
144146
init_discussion_history(ws_path, topic_title, topic_body)
145147

148+
# Copy user-selected skills from global assignable_skills to config/skills/
149+
if skill_list:
150+
copied = copy_skills_to_workspace(ws_path, skill_list)
151+
if copied:
152+
logger.info(f"Copied {len(copied)} skills to workspace: {copied}")
153+
146154
config = get_agent_config()
147155
if model:
148156
config = {**config, "model": model}

app/agent/moderator_modes.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,13 +167,38 @@ def prepare_moderator_skill(ws_path: Path, topic: str, expert_names: list[str],
167167
mode_id = "standard"
168168
skill_content = _build_moderator_prompt_from_preset(mode_id, params)
169169

170+
# Append skill assignment section when config/skills/ has assignable skills
171+
skills_dir = ws_path / "config" / "skills"
172+
if skills_dir.exists():
173+
skill_files = sorted(skills_dir.glob("*.md"))
174+
if skill_files:
175+
skill_names = [f.stem for f in skill_files]
176+
assignment_section = _build_skill_assignment_section(skill_names)
177+
skill_content = skill_content.rstrip() + "\n\n" + assignment_section
178+
logger.info(f"Added skill assignment section for {skill_names}")
179+
170180
skill_file = ws_path / "config" / "moderator_skill.md"
171181
skill_file.parent.mkdir(parents=True, exist_ok=True)
172182
skill_file.write_text(skill_content, encoding="utf-8")
173183
logger.info(f"Saved moderator skill to {skill_file} (mode={mode_id}, rounds={num_rounds})")
174184
return skill_file
175185

176186

187+
def _build_skill_assignment_section(skill_names: list[str]) -> str:
188+
"""Build moderator instructions for assigning skills to experts."""
189+
paths_str = "\n".join(f"- config/skills/{s}.md" for s in skill_names)
190+
return f"""## Skill Assignment (config/skills/)
191+
192+
以下技能已拷贝到工作区,供你按需分配给专家:
193+
194+
{paths_str}
195+
196+
**使用方式**:
197+
1. 每轮开始前,用 Read 工具阅读上述技能文件,根据当前讨论阶段与话题选择最相关的技能
198+
2. 调用专家 Task 时,在指令中附加技能内容,例如:「除你的角色外,请额外遵循以下指导:[粘贴技能内容]。然后阅读 shared/topic.md 并参与讨论。」
199+
3. 同一专家可分配多个技能,或不同专家分配不同技能;根据话题与专家专长灵活选择"""
200+
201+
177202
def get_moderator_prompt(ws_path: Path) -> str:
178203
"""Return the short prompt that instructs the moderator to read its skill file.
179204

app/agent/workspace.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,107 @@ def init_discussion_history(ws_path: Path, topic_title: str, topic_body: str) ->
7373
return turns_dir
7474

7575

76+
def _parse_skill_id(skill_id: str) -> tuple[str, str]:
77+
"""Parse skill_id into (source, slug). Supports source-prefixed format: source:slug."""
78+
raw = skill_id.removesuffix(".md") if skill_id.endswith(".md") else skill_id
79+
if ":" in raw:
80+
parts = raw.split(":", 1)
81+
return parts[0], parts[1]
82+
return "", raw
83+
84+
85+
def _resolve_skill_path(base_dir: Path, skill_id: str, skill_info: dict) -> Path | None:
86+
"""Resolve source file path for a skill. Returns None if not found.
87+
88+
- default: assignable_skills/default/{category}/{slug}.md
89+
- imported (submodule): assignable_skills/_submodules/{source}/{skills_dir}/{category}/{slug}/SKILL.md
90+
or {skills_dir}/{slug}/SKILL.md when category is 'general'
91+
"""
92+
_, slug = _parse_skill_id(skill_id)
93+
source = skill_info.get("source", "default") or "default"
94+
category = skill_info.get("category", "")
95+
96+
submodules = base_dir / "_submodules" / source
97+
if source != "default" and submodules.exists():
98+
skills_dir = skill_info.get("_skills_dir", ".") or "."
99+
if category and category != "general":
100+
path = base_dir / "_submodules" / source / skills_dir / category / slug / "SKILL.md"
101+
else:
102+
path = base_dir / "_submodules" / source / skills_dir / slug / "SKILL.md"
103+
return path if path.exists() else None
104+
105+
if not category:
106+
return base_dir / source / f"{slug}.md"
107+
return base_dir / source / category / f"{slug}.md"
108+
109+
110+
def _skill_dest_filename(skill_id: str) -> str:
111+
"""Destination filename in config/skills/. Replaces : with _ to avoid collisions."""
112+
_, slug = _parse_skill_id(skill_id)
113+
if ":" in (skill_id.removesuffix(".md") if skill_id.endswith(".md") else skill_id):
114+
return f"{skill_id.replace(':', '_')}.md"
115+
return f"{slug}.md"
116+
117+
118+
def copy_skills_to_workspace(ws_path: Path, skill_list: list[str]) -> list[str]:
119+
"""Copy selected skills from global assignable_skills to topic workspace config/skills/.
120+
121+
Supports source-prefixed IDs (e.g. awesome:critical_thinking) to avoid collisions
122+
when importing from multiple skill libraries.
123+
124+
Path rule: assignable_skills/{source}/{category}/{slug}.md (all sources)
125+
126+
Args:
127+
ws_path: Topic workspace path (workspace/topics/{topic_id})
128+
skill_list: List of skill ids (e.g. ["research_methodology", "awesome:critical_thinking"])
129+
130+
Returns:
131+
List of skill ids that were successfully copied.
132+
"""
133+
if not skill_list:
134+
return []
135+
136+
from app.core.config import get_assignable_skills_dir
137+
from app.core.skills_meta import load_aggregated_meta
138+
139+
base_dir = get_assignable_skills_dir()
140+
_, skills_meta = load_aggregated_meta(base_dir)
141+
if not skills_meta:
142+
logger.warning("Assignable skills meta not found")
143+
return []
144+
145+
# Allow alphanumeric, underscore, hyphen, colon (for source:slug)
146+
id_pattern = re.compile(r"^[a-zA-Z0-9_.-]+(:[a-zA-Z0-9_.-]+)*$")
147+
148+
dest_dir = ws_path / "config" / "skills"
149+
dest_dir.mkdir(parents=True, exist_ok=True)
150+
151+
copied: list[str] = []
152+
for skill_id in skill_list:
153+
raw = skill_id.removesuffix(".md") if skill_id.endswith(".md") else skill_id
154+
if not id_pattern.match(raw):
155+
logger.warning(f"Invalid skill id (skipped): {skill_id}")
156+
continue
157+
158+
skill_info = skills_meta.get(raw, {}) if isinstance(skills_meta.get(raw), dict) else {}
159+
if not skill_info:
160+
logger.warning(f"Skill not found in meta (skipped): {raw}")
161+
continue
162+
163+
src = _resolve_skill_path(base_dir, raw, skill_info)
164+
if not src or not src.exists():
165+
logger.warning(f"Skill file not found (skipped): {src}")
166+
continue
167+
168+
dest_name = _skill_dest_filename(raw)
169+
dest = dest_dir / dest_name
170+
dest.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
171+
copied.append(raw)
172+
logger.info(f"Copied skill {raw} to {dest}")
173+
174+
return copied
175+
176+
76177
def _get_expert_label(expert_key: str, ws_path: Path) -> str:
77178
"""Map expert key to display label.
78179

app/api/discussion.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
from fastapi import APIRouter, HTTPException
77

88
from app.agent.discussion import run_discussion_for_topic
9-
from app.agent.workspace import get_topic_experts, read_discussion_history, read_discussion_summary, validate_topic_id
9+
from app.agent.workspace import (
10+
copy_skills_to_workspace,
11+
get_topic_experts,
12+
read_discussion_history,
13+
read_discussion_summary,
14+
validate_topic_id,
15+
)
1016
from app.core.config import get_workspace_base
1117
from app.models.schemas import (
1218
DEFAULT_ALLOWED_TOOLS,
@@ -37,6 +43,7 @@ async def run_discussion_background(
3743
max_budget_usd: float,
3844
model: str | None = None,
3945
allowed_tools: list[str] | None = None,
46+
skill_list: list[str] | None = None,
4047
):
4148
"""Background task to run discussion."""
4249
try:
@@ -52,6 +59,7 @@ async def run_discussion_background(
5259
max_budget_usd=max_budget_usd,
5360
model=model,
5461
allowed_tools=allowed_tools,
62+
skill_list=skill_list or [],
5563
)
5664
logger.info(f"Discussion completed for topic {topic_id}, result: {result}")
5765
typed_result = DiscussionResult(**result)
@@ -103,6 +111,7 @@ async def start_discussion_endpoint(topic_id: str, req: StartDiscussionRequest):
103111
max_budget_usd=req.max_budget_usd,
104112
model=req.model,
105113
allowed_tools=tools,
114+
skill_list=req.skill_list or [],
106115
))
107116

108117
return DiscussionStatusResponse(

0 commit comments

Comments
 (0)