diff --git a/README.md b/README.md index 6f0cf88..558dbdd 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,39 @@ Or use the helper script: ./scripts/run-mini-forensics-agent --help ``` +Experimental Agent Skills scaffold: + +```bash +uv run mini-forensics-agent \ + --model 'LocoOperator-4B-mlx-4Bit' \ + --workspace /path/to/workspace \ + --task 'Inspect this repo and use any matching skill if needed.' \ + --enable-skills \ + --stream +``` + +Skill discovery roots, in increasing precedence: +- `~/.agents/skills` +- `~/.mini-forensics-agent/skills` +- `/.agents/skills` +- `/.mini-forensics-agent/skills` +- any extra `--skill-dir /path/to/skills` + +Each skill lives in its own directory and must include a `SKILL.md` with frontmatter: + +```md +--- +name: demo-skill +description: One-line summary of when the skill should be used +--- + +# Instructions +``` + +Useful skill commands: +- `uv run mini-forensics-agent --workspace /path/to/workspace --list-skills` +- `uv run mini-forensics-agent --workspace /path/to/workspace --enable-skills --skill-dir /extra/skills ...` + ## TUI ```bash diff --git a/src/miniforensicsagent/cli.py b/src/miniforensicsagent/cli.py index ee5b789..193cf3c 100644 --- a/src/miniforensicsagent/cli.py +++ b/src/miniforensicsagent/cli.py @@ -2,6 +2,7 @@ import argparse import json +import sys import time from pathlib import Path @@ -14,6 +15,7 @@ patch_mlx_lm_prompt_cache_with_turboquant, resolve_model, ) +from .skills import discover_skills def main() -> int: @@ -31,7 +33,7 @@ def main() -> int: parser.add_argument("--turboquant", action="store_true") parser.add_argument("--tq-r-bits", type=int, default=4) parser.add_argument("--tq-theta-bits", type=int, default=4) - parser.add_argument("--task", required=True) + parser.add_argument("--task", default="") parser.add_argument("--json-out", default="") parser.add_argument("--stream", action="store_true") parser.add_argument("--list-models", action="store_true") @@ -39,18 +41,35 @@ def main() -> int: parser.add_argument("--compress-observations", action="store_true", help="[exp] Compress old observation payloads to reduce prompt size (O(n²) → O(n) tokens).") parser.add_argument("--transcript-window", type=int, default=None, metavar="K", help="[exp] Only include the last K turns in each prompt (sliding window).") parser.add_argument("--multi-tool", action="store_true", help="[exp] Allow multiple independent tool calls per turn.") + parser.add_argument("--enable-skills", action="store_true", help="[exp] Discover Agent Skills and expose activation tools to the model.") + parser.add_argument("--skill-dir", action="append", default=[], help="[exp] Additional Agent Skills root to scan. May be passed multiple times.") + parser.add_argument("--list-skills", action="store_true", help="List discovered skills and exit.") args = parser.parse_args() root = Path(args.model_root).expanduser().resolve() models = discover_models(root) + workspace = Path(args.workspace).expanduser().resolve() + skill_catalog = discover_skills(workspace, extra_dirs=args.skill_dir) if (args.enable_skills or args.list_skills) else None if args.list_models: for model in models: print(f"{model.name}\t{model.path}") return 0 + if args.list_skills: + if skill_catalog is None: + return 0 + for diagnostic in skill_catalog.diagnostics: + print(f"[skills] {diagnostic}", file=sys.stderr) + for skill in skill_catalog.skills: + print(f"{skill.name}\t{skill.skill_file}\t{skill.description}") + return 0 + if not args.task.strip(): + parser.error("--task is required unless --list-models or --list-skills is used.") selected = resolve_model(args.model, models, root) - workspace = Path(args.workspace).expanduser().resolve() started = time.perf_counter() model, generation_config = load_local_model(selected.path) + if args.enable_skills and skill_catalog is not None: + for diagnostic in skill_catalog.diagnostics: + print(f"[skills] {diagnostic}", file=sys.stderr) if args.turboquant: patch_mlx_lm_prompt_cache_with_turboquant(r_bits=args.tq_r_bits, theta_bits=args.tq_theta_bits) result = run_loop( @@ -69,6 +88,7 @@ def main() -> int: compress_observations=args.compress_observations, transcript_window=args.transcript_window, multi_tool=args.multi_tool, + skill_catalog=skill_catalog if args.enable_skills else None, ) payload = { "model": selected.name, @@ -79,6 +99,11 @@ def main() -> int: "tool_calls": result.tool_calls, "elapsed_seconds": round(time.perf_counter() - started, 3), "workspace": str(workspace), + "skills": { + "enabled": bool(args.enable_skills), + "roots": [str(path) for path in (skill_catalog.roots if skill_catalog is not None else ())], + "discovered": [skill.name for skill in (skill_catalog.skills if skill_catalog is not None else ())], + }, "kv_cache_quantization": { "kv_bits": args.kv_bits, "kv_group_size": args.kv_group_size, diff --git a/src/miniforensicsagent/loop.py b/src/miniforensicsagent/loop.py index f8e691d..668914d 100644 --- a/src/miniforensicsagent/loop.py +++ b/src/miniforensicsagent/loop.py @@ -14,10 +14,11 @@ ) from .prompting import build_prompt from .render import HAS_RICH, Live, RICH_STDERR, build_status_renderable, count_tokens, emit_observation_rendered, start_prefill_indicator -from .tools import DEFAULT_READ_LIMIT, run_tool +from .skills import SkillCatalog, render_active_skill_context, render_skill_catalog +from .tools import run_tool -TOOL_NAMES = {"Read", "Glob", "Grep", "Bash", "Write", "Edit"} +TOOL_NAMES = {"Read", "Glob", "Grep", "Bash", "Write", "Edit", "ActivateSkill", "ReadSkillResource"} @dataclass @@ -249,6 +250,15 @@ def compress_turn(turn: dict[str, Any]) -> None: obs["output"] = f"[compressed: Bash output {len(output)} chars, seen at iter {iteration}]" +def update_active_skills(active_skills: dict[str, dict[str, Any]], decision: dict[str, Any], observation: dict[str, Any]) -> None: + if decision.get("type") != "tool" or not observation.get("ok"): + return + if decision.get("name") == "ActivateSkill": + skill_name = str(observation.get("name", "")).strip() + if skill_name: + active_skills[skill_name] = dict(observation) + + def run_loop( model: Any, generation_config: Any, @@ -269,6 +279,7 @@ def run_loop( compress_observations: bool = False, transcript_window: int | None = None, multi_tool: bool = False, + skill_catalog: SkillCatalog | None = None, ) -> LoopResult: transcript: list[dict[str, Any]] = [] tool_calls = 0 @@ -277,6 +288,8 @@ def run_loop( narrow_empty_search_streak = 0 plan_state: dict[str, Any] | None = None running_tool_stats: dict[str, Any] = {"counts": {}, "failures": 0} + active_skills: dict[str, dict[str, Any]] = {} + available_skills_block = render_skill_catalog(skill_catalog) if skill_catalog is not None else "" for iteration in range(1, max_iterations + 1): if should_stop is not None and should_stop(): @@ -315,6 +328,8 @@ def run_loop( reflection_hint=reflection_hint, window=transcript_window, multi_tool=multi_tool, + available_skills=available_skills_block, + active_skill_context=render_active_skill_context(active_skills), ) tokenizer = getattr(model, "tokenizer", None) prompt_tokens = count_tokens(tokenizer, prompt) if tokenizer is not None else None @@ -471,9 +486,15 @@ def run_loop( for call in normalized_calls: if should_stop is not None and should_stop(): return LoopResult(False, "cancelled", iteration, tool_calls, transcript) - obs = run_tool(call, workspace) + obs = run_tool( + call, + workspace, + skill_catalog=skill_catalog, + active_skill_names=set(active_skills), + ) tool_calls += 1 observations.append(obs) + update_active_skills(active_skills, call, obs) added_evidence_total += update_evidence_cache(evidence_cache, call, obs) turn["decisions"] = normalized_calls turn["decision"] = normalized_calls[0] # keep compat for reflection hints @@ -591,8 +612,14 @@ def run_loop( if should_stop is not None and should_stop(): return LoopResult(False, "cancelled", iteration, tool_calls, transcript) - observation = run_tool(response, workspace) + observation = run_tool( + response, + workspace, + skill_catalog=skill_catalog, + active_skill_names=set(active_skills), + ) tool_calls += 1 + update_active_skills(active_skills, response, observation) turn["observations"] = [observation] added_evidence = update_evidence_cache(evidence_cache, response, observation) turn["evidence_cache_size"] = len(evidence_cache) diff --git a/src/miniforensicsagent/prompting.py b/src/miniforensicsagent/prompting.py index b752676..6cd70bd 100644 --- a/src/miniforensicsagent/prompting.py +++ b/src/miniforensicsagent/prompting.py @@ -13,6 +13,8 @@ def build_prompt( reflection_hint: str = "", window: int | None = None, multi_tool: bool = False, + available_skills: str = "", + active_skill_context: str = "", ) -> str: visible = transcript[-window:] if window is not None and window > 0 else transcript history = json.dumps(visible, ensure_ascii=False, indent=2) if visible else "[]" @@ -86,6 +88,27 @@ def build_prompt( "- or {\"type\":\"plan_update\",\"completed_steps\":[\"step\"],\"current_step\":\"step\"}\n" "- or {\"answer\":\"done\"}" ) + skill_block = "" + skill_tools = "" + skill_rules = "" + if available_skills: + skill_block = ( + "Available skills:\n" + f"{available_skills}\n" + ) + skill_tools = ( + "- ActivateSkill(skill_name)\n" + "- ReadSkillResource(skill_name, file_path, offset=1, limit=120)\n" + ) + skill_rules = ( + "Skills:\n" + "- If one of the listed skills clearly matches the task, activate it before following its detailed instructions.\n" + "- Once a skill is activated, treat its instructions as active guidance for the rest of the run.\n" + "- Only read skill resources after activating that skill, and prefer the specific files the skill references.\n" + ) + active_skill_block = "" + if active_skill_context: + active_skill_block = f"{active_skill_context}\n\n" return f"""You are a local codebase explorer. Use Claude Code style tool calls. {turn_format} @@ -101,6 +124,7 @@ def build_prompt( - Bash(command) [allowed: pwd, ls, find, cat] - Write(file_path, content) - Edit(file_path, old_string, new_string) +{skill_tools} Forensics mode: - Treat the workspace as an evidence snapshot, not a live system. @@ -118,13 +142,14 @@ def build_prompt( - Do not keep increasing Read from offset=1 unless no line-targeted option exists. - If the last observation failed, fix it instead of finishing. - Goal is artifact discovery, not long explanation. +{skill_rules} {plan_block} {convergence_block} {final_only_prefix} {plan_instruction} {few_shot} -{reflection_hint} +{skill_block}{active_skill_block}{reflection_hint} Task: {task} diff --git a/src/miniforensicsagent/skills.py b/src/miniforensicsagent/skills.py new file mode 100644 index 0000000..32323bd --- /dev/null +++ b/src/miniforensicsagent/skills.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +from dataclasses import dataclass +from html import escape +from pathlib import Path +from typing import Any, Iterable +import re + + +DEFAULT_SKILL_READ_LIMIT = 120 +MAX_SKILL_RESOURCE_PREVIEW = 24 + + +@dataclass(frozen=True) +class SkillRecord: + name: str + description: str + skill_file: Path + metadata: dict[str, str] + content: str + + @property + def skill_dir(self) -> Path: + return self.skill_file.parent + + +@dataclass(frozen=True) +class SkillCatalog: + skills: tuple[SkillRecord, ...] + diagnostics: tuple[str, ...] + roots: tuple[Path, ...] + + def by_name(self) -> dict[str, SkillRecord]: + return {skill.name: skill for skill in self.skills} + + +def candidate_skill_roots(project_root: Path, extra_dirs: Iterable[str] = ()) -> tuple[Path, ...]: + raw_roots = [ + Path.home() / ".agents" / "skills", + Path.home() / ".mini-forensics-agent" / "skills", + project_root / ".agents" / "skills", + project_root / ".mini-forensics-agent" / "skills", + *(Path(raw).expanduser() for raw in extra_dirs), + ] + seen: set[Path] = set() + resolved: list[Path] = [] + for root in raw_roots: + root = root.resolve() + if root in seen: + continue + seen.add(root) + resolved.append(root) + return tuple(resolved) + + +def discover_skills(project_root: Path, extra_dirs: Iterable[str] = ()) -> SkillCatalog: + skills_by_name: dict[str, SkillRecord] = {} + diagnostics: list[str] = [] + roots = candidate_skill_roots(project_root, extra_dirs=extra_dirs) + for root in roots: + if not root.exists(): + continue + for skill_file in sorted(root.rglob("SKILL.md")): + try: + skill = parse_skill_file(skill_file) + except Exception as exc: + diagnostics.append(f"Failed to parse skill {skill_file}: {exc}") + continue + previous = skills_by_name.get(skill.name) + if previous is not None and previous.skill_file != skill.skill_file: + diagnostics.append( + f"Skill {skill.name!r} from {skill.skill_file} overrides {previous.skill_file}" + ) + skills_by_name[skill.name] = skill + return SkillCatalog( + skills=tuple(sorted(skills_by_name.values(), key=lambda item: item.name.lower())), + diagnostics=tuple(diagnostics), + roots=roots, + ) + + +def parse_skill_file(skill_file: Path) -> SkillRecord: + raw = skill_file.read_text(encoding="utf-8") + match = re.match(r"(?s)\A---\s*\n(.*?)\n---\s*\n?(.*)\Z", raw) + if match is None: + raise ValueError("missing YAML frontmatter") + metadata = _parse_frontmatter(match.group(1)) + name = metadata.get("name", "").strip() + description = metadata.get("description", "").strip() + if not name: + raise ValueError("frontmatter is missing name") + if not description: + raise ValueError("frontmatter is missing description") + return SkillRecord( + name=name, + description=description, + skill_file=skill_file.resolve(), + metadata=metadata, + content=match.group(2).strip(), + ) + + +def _parse_frontmatter(frontmatter: str) -> dict[str, str]: + metadata: dict[str, str] = {} + lines = frontmatter.splitlines() + index = 0 + while index < len(lines): + line = lines[index] + stripped = line.strip() + index += 1 + if not stripped or stripped.startswith("#"): + continue + if ":" not in line: + raise ValueError(f"invalid frontmatter line: {line!r}") + key, raw_value = line.split(":", 1) + key = key.strip() + value = raw_value.strip() + if value in {"|", ">"}: + block: list[str] = [] + while index < len(lines): + next_line = lines[index] + if next_line.strip() and not next_line.startswith((" ", "\t")): + break + block.append(next_line.lstrip()) + index += 1 + metadata[key] = "\n".join(block).strip() + continue + metadata[key] = _strip_yaml_scalar(value) + return metadata + + +def _strip_yaml_scalar(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + +def render_skill_catalog(catalog: SkillCatalog) -> str: + if not catalog.skills: + return "" + lines = [""] + for skill in catalog.skills: + lines.extend( + [ + " ", + f" {escape(skill.name)}", + f" {escape(skill.description)}", + f" {escape(str(skill.skill_file))}", + " ", + ] + ) + lines.append("") + return "\n".join(lines) + + +def render_active_skill_context(active_skills: dict[str, dict[str, Any]]) -> str: + if not active_skills: + return "" + sections = ["Activated skills:"] + for name in sorted(active_skills): + skill = active_skills[name] + sections.extend( + [ + f"", + f"description: {skill['description']}", + f"location: {skill['location']}", + ] + ) + resources = skill.get("resource_files", []) + if resources: + sections.append("resource_files:") + sections.extend(f"- {item}" for item in resources) + sections.append("instructions:") + sections.append(str(skill.get("content", "")).strip()) + sections.append("") + return "\n".join(sections) + + +def activate_skill(catalog: SkillCatalog, skill_name: str) -> dict[str, Any]: + skill = catalog.by_name().get(skill_name) + if skill is None: + return {"ok": False, "error": f"Unknown skill: {skill_name}"} + resource_files = list_skill_resources(skill.skill_dir) + payload: dict[str, Any] = { + "ok": True, + "name": skill.name, + "description": skill.description, + "location": str(skill.skill_file), + "content": skill.content, + "resource_files": resource_files, + } + for field in ("allowed-tools", "compatibility", "version"): + if skill.metadata.get(field): + payload[field.replace("-", "_")] = skill.metadata[field] + return payload + + +def list_skill_resources(skill_dir: Path) -> list[str]: + resources: list[str] = [] + for path in sorted(skill_dir.rglob("*")): + if not path.is_file(): + continue + if path.name == "SKILL.md": + continue + try: + rel = str(path.relative_to(skill_dir)) + except ValueError: + continue + resources.append(rel) + if len(resources) >= MAX_SKILL_RESOURCE_PREVIEW: + break + return resources + + +def read_skill_resource( + catalog: SkillCatalog, + skill_name: str, + relative_path: str, + *, + offset: int = 1, + limit: int | str = DEFAULT_SKILL_READ_LIMIT, + active_skill_names: set[str] | None = None, +) -> dict[str, Any]: + if active_skill_names is not None and skill_name not in active_skill_names: + return {"ok": False, "error": f"Skill is not active: {skill_name}"} + skill = catalog.by_name().get(skill_name) + if skill is None: + return {"ok": False, "error": f"Unknown skill: {skill_name}"} + candidate = (skill.skill_dir / relative_path).resolve() + if candidate != skill.skill_dir and skill.skill_dir not in candidate.parents: + return {"ok": False, "error": f"Path escapes skill directory: {relative_path}"} + if not candidate.exists() or not candidate.is_file(): + return {"ok": False, "error": f"Skill resource not found: {relative_path}"} + try: + lines = candidate.read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError: + return {"ok": False, "error": f"Skill resource is not UTF-8 text: {relative_path}"} + start = max(1, int(offset)) + start_index = start - 1 + if str(limit).strip().lower() in {"end", "eof", "-1"}: + parsed_limit = max(1, len(lines) - start_index) + end_index = len(lines) + else: + parsed_limit = max(1, int(limit)) + end_index = start_index + parsed_limit + chunk = lines[start_index:end_index] + return { + "ok": True, + "skill_name": skill.name, + "file_path": relative_path, + "content": "\n".join(chunk), + "offset": start, + "limit": parsed_limit, + "returned_lines": len(chunk), + "total_lines": len(lines), + "truncated": end_index < len(lines), + } diff --git a/src/miniforensicsagent/tools.py b/src/miniforensicsagent/tools.py index 7f1d683..ca02ddb 100644 --- a/src/miniforensicsagent/tools.py +++ b/src/miniforensicsagent/tools.py @@ -9,6 +9,8 @@ from pathlib import Path from typing import Any +from .skills import DEFAULT_SKILL_READ_LIMIT, SkillCatalog, activate_skill, read_skill_resource + DEFAULT_READ_LIMIT = 80 MAX_MATCH_PREVIEW = 80 @@ -26,7 +28,13 @@ def expand_brace_pattern(pattern: str) -> list[str]: return expanded -def run_tool(call: dict[str, Any], workspace: Path) -> dict[str, Any]: +def run_tool( + call: dict[str, Any], + workspace: Path, + *, + skill_catalog: SkillCatalog | None = None, + active_skill_names: set[str] | None = None, +) -> dict[str, Any]: tool = call["name"] args = call["arguments"] workspace = workspace.resolve() @@ -125,6 +133,23 @@ def parse_positive_int(value: Any, default: int) -> int: return default try: + if tool == "ActivateSkill": + if skill_catalog is None: + return {"ok": False, "error": "Skills are not enabled for this run."} + return activate_skill(skill_catalog, str(args.get("skill_name", ""))) + + if tool == "ReadSkillResource": + if skill_catalog is None: + return {"ok": False, "error": "Skills are not enabled for this run."} + return read_skill_resource( + skill_catalog, + str(args.get("skill_name", "")), + str(args.get("file_path", "")), + offset=args.get("offset", 1), + limit=args.get("limit", DEFAULT_SKILL_READ_LIMIT), + active_skill_names=active_skill_names, + ) + if tool == "Read": file_path = resolve_inside_workspace(str(args["file_path"])) offset = parse_positive_int(args.get("offset", args.get("start_line", 1)), 1) diff --git a/tests/test_skills.py b/tests/test_skills.py new file mode 100644 index 0000000..0e898f5 --- /dev/null +++ b/tests/test_skills.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from miniforensicsagent.skills import discover_skills, parse_skill_file, read_skill_resource + + +class SkillsTest(unittest.TestCase): + def test_parse_skill_frontmatter_accepts_colons_in_description(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + skill_file = Path(tmp) / "SKILL.md" + skill_file.write_text( + "---\n" + "name: demo-skill\n" + "description: Handles prompts: carefully and safely\n" + "---\n" + "\n" + "# Demo\n", + encoding="utf-8", + ) + skill = parse_skill_file(skill_file) + self.assertEqual(skill.name, "demo-skill") + self.assertEqual(skill.description, "Handles prompts: carefully and safely") + + def test_discover_skills_prefers_native_project_dir(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + legacy_skill = root / ".agents" / "skills" / "demo" / "SKILL.md" + native_skill = root / ".mini-forensics-agent" / "skills" / "demo" / "SKILL.md" + legacy_skill.parent.mkdir(parents=True, exist_ok=True) + native_skill.parent.mkdir(parents=True, exist_ok=True) + legacy_skill.write_text( + "---\nname: demo\ndescription: legacy\n---\nLegacy body\n", + encoding="utf-8", + ) + native_skill.write_text( + "---\nname: demo\ndescription: native\n---\nNative body\n", + encoding="utf-8", + ) + + catalog = discover_skills(root) + demo_skills = [skill for skill in catalog.skills if skill.name == "demo"] + + self.assertEqual(len(demo_skills), 1) + self.assertEqual(demo_skills[0].description, "native") + self.assertIn("overrides", "\n".join(catalog.diagnostics)) + + def test_read_skill_resource_requires_activation(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + skill_file = root / ".mini-forensics-agent" / "skills" / "demo" / "SKILL.md" + resource_file = skill_file.parent / "references" / "notes.md" + resource_file.parent.mkdir(parents=True, exist_ok=True) + skill_file.write_text( + "---\nname: demo\ndescription: demo skill\n---\nUse references/notes.md\n", + encoding="utf-8", + ) + resource_file.write_text("line1\nline2\nline3\n", encoding="utf-8") + + catalog = discover_skills(root) + blocked = read_skill_resource(catalog, "demo", "references/notes.md", active_skill_names=set()) + allowed = read_skill_resource(catalog, "demo", "references/notes.md", offset=2, limit=2, active_skill_names={"demo"}) + + self.assertFalse(blocked["ok"]) + self.assertTrue(allowed["ok"]) + self.assertEqual(allowed["content"], "line2\nline3") diff --git a/tests/test_tools.py b/tests/test_tools.py index 6e60d37..adcba81 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -4,6 +4,7 @@ import unittest from pathlib import Path +from miniforensicsagent.skills import discover_skills from miniforensicsagent.tools import run_tool @@ -103,3 +104,34 @@ class DummyModel: caches = cache_module.make_prompt_cache(DummyModel()) self.assertEqual(len(caches), 3) + + def test_activate_skill_and_read_resource_tool(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + skill_file = root / ".mini-forensics-agent" / "skills" / "demo" / "SKILL.md" + reference = skill_file.parent / "references" / "guide.md" + reference.parent.mkdir(parents=True, exist_ok=True) + skill_file.write_text( + "---\nname: demo\ndescription: demo skill\n---\nRead references/guide.md\n", + encoding="utf-8", + ) + reference.write_text("alpha\nbeta\n", encoding="utf-8") + catalog = discover_skills(root) + + activated = run_tool( + {"name": "ActivateSkill", "arguments": {"skill_name": "demo"}}, + root, + skill_catalog=catalog, + active_skill_names=set(), + ) + resource = run_tool( + {"name": "ReadSkillResource", "arguments": {"skill_name": "demo", "file_path": "references/guide.md", "offset": 2, "limit": 1}}, + root, + skill_catalog=catalog, + active_skill_names={"demo"}, + ) + + self.assertTrue(activated["ok"]) + self.assertIn("references/guide.md", activated["resource_files"]) + self.assertTrue(resource["ok"]) + self.assertEqual(resource["content"], "beta")