/ tests / tools / test_skill_size_limits.py
test_skill_size_limits.py
  1  """Tests for skill content size limits.
  2  
  3  Agent writes (create/edit/patch/write_file) are constrained to
  4  MAX_SKILL_CONTENT_CHARS (100k) and MAX_SKILL_FILE_BYTES (1 MiB).
  5  Hand-placed and hub-installed skills have no hard limit.
  6  """
  7  
  8  import json
  9  import os
 10  from pathlib import Path
 11  from unittest.mock import patch
 12  
 13  import pytest
 14  
 15  from tools.skill_manager_tool import (
 16      MAX_SKILL_CONTENT_CHARS,
 17      MAX_SKILL_FILE_BYTES,
 18      _validate_content_size,
 19      skill_manage,
 20  )
 21  
 22  
 23  @pytest.fixture(autouse=True)
 24  def isolate_skills(tmp_path, monkeypatch):
 25      """Redirect SKILLS_DIR to a temp directory."""
 26      skills_dir = tmp_path / "skills"
 27      skills_dir.mkdir()
 28      monkeypatch.setattr("tools.skill_manager_tool.SKILLS_DIR", skills_dir)
 29      monkeypatch.setattr("tools.skills_tool.SKILLS_DIR", skills_dir)
 30      monkeypatch.setenv("HERMES_HOME", str(tmp_path))
 31      return skills_dir
 32  
 33  
 34  def _make_skill_content(body_chars: int) -> str:
 35      """Generate valid SKILL.md content with a body of the given character count."""
 36      frontmatter = (
 37          "---\n"
 38          "name: test-skill\n"
 39          "description: A test skill\n"
 40          "---\n"
 41      )
 42      body = "# Test Skill\n\n" + ("x" * max(0, body_chars - 15))
 43      return frontmatter + body
 44  
 45  
 46  class TestValidateContentSize:
 47      """Unit tests for _validate_content_size."""
 48  
 49      def test_within_limit(self):
 50          assert _validate_content_size("a" * 1000) is None
 51  
 52      def test_at_limit(self):
 53          assert _validate_content_size("a" * MAX_SKILL_CONTENT_CHARS) is None
 54  
 55      def test_over_limit(self):
 56          err = _validate_content_size("a" * (MAX_SKILL_CONTENT_CHARS + 1))
 57          assert err is not None
 58          assert "100,001" in err
 59          assert "100,000" in err
 60  
 61      def test_custom_label(self):
 62          err = _validate_content_size("a" * (MAX_SKILL_CONTENT_CHARS + 1), label="references/api.md")
 63          assert "references/api.md" in err
 64  
 65  
 66  class TestCreateSkillSizeLimit:
 67      """create action rejects oversized content."""
 68  
 69      def test_create_within_limit(self, isolate_skills):
 70          content = _make_skill_content(5000)
 71          result = json.loads(skill_manage(action="create", name="small-skill", content=content))
 72          assert result["success"] is True
 73  
 74      def test_create_over_limit(self, isolate_skills):
 75          content = _make_skill_content(MAX_SKILL_CONTENT_CHARS + 100)
 76          result = json.loads(skill_manage(action="create", name="huge-skill", content=content))
 77          assert result["success"] is False
 78          assert "100,000" in result["error"]
 79  
 80      def test_create_at_limit(self, isolate_skills):
 81          # Content at exactly the limit should succeed
 82          frontmatter = "---\nname: edge-skill\ndescription: Edge case\n---\n# Edge\n\n"
 83          body_budget = MAX_SKILL_CONTENT_CHARS - len(frontmatter)
 84          content = frontmatter + ("x" * body_budget)
 85          assert len(content) == MAX_SKILL_CONTENT_CHARS
 86          result = json.loads(skill_manage(action="create", name="edge-skill", content=content))
 87          assert result["success"] is True
 88  
 89  
 90  class TestEditSkillSizeLimit:
 91      """edit action rejects oversized content."""
 92  
 93      def test_edit_over_limit(self, isolate_skills):
 94          # Create a small skill first
 95          small = _make_skill_content(1000)
 96          json.loads(skill_manage(action="create", name="grow-me", content=small))
 97  
 98          # Try to edit it to be oversized
 99          big = _make_skill_content(MAX_SKILL_CONTENT_CHARS + 100)
100          # Fix the name in frontmatter
101          big = big.replace("name: test-skill", "name: grow-me")
102          result = json.loads(skill_manage(action="edit", name="grow-me", content=big))
103          assert result["success"] is False
104          assert "100,000" in result["error"]
105  
106  
107  class TestPatchSkillSizeLimit:
108      """patch action checks resulting size, not just the new_string."""
109  
110      def test_patch_that_would_exceed_limit(self, isolate_skills):
111          # Create a skill near the limit
112          near_limit = _make_skill_content(MAX_SKILL_CONTENT_CHARS - 50)
113          json.loads(skill_manage(action="create", name="near-limit", content=near_limit))
114  
115          # Patch that adds enough to go over
116          result = json.loads(skill_manage(
117              action="patch",
118              name="near-limit",
119              old_string="# Test Skill",
120              new_string="# Test Skill\n" + ("y" * 200),
121          ))
122          assert result["success"] is False
123          assert "100,000" in result["error"]
124  
125      def test_patch_that_reduces_size_on_oversized_skill(self, isolate_skills, tmp_path):
126          """Patches that shrink an already-oversized skill should succeed."""
127          # Manually create an oversized skill (simulating hand-placed)
128          skill_dir = tmp_path / "skills" / "bloated"
129          skill_dir.mkdir(parents=True)
130          oversized = _make_skill_content(MAX_SKILL_CONTENT_CHARS + 5000)
131          oversized = oversized.replace("name: test-skill", "name: bloated")
132          (skill_dir / "SKILL.md").write_text(oversized, encoding="utf-8")
133          assert len(oversized) > MAX_SKILL_CONTENT_CHARS
134  
135          # Patch that removes content to bring it under the limit.
136          # Use replace_all to replace the repeated x's with a shorter string.
137          result = json.loads(skill_manage(
138              action="patch",
139              name="bloated",
140              old_string="x" * 100,
141              new_string="y",
142              replace_all=True,
143          ))
144          # Should succeed because the result is well within limits
145          assert result["success"] is True
146  
147      def test_patch_supporting_file_size_limit(self, isolate_skills):
148          """Patch on a supporting file also checks size."""
149          small = _make_skill_content(1000)
150          json.loads(skill_manage(action="create", name="with-ref", content=small))
151          # Create a supporting file
152          json.loads(skill_manage(
153              action="write_file",
154              name="with-ref",
155              file_path="references/data.md",
156              file_content="# Data\n\nSmall content.",
157          ))
158          # Try to patch it to be oversized
159          result = json.loads(skill_manage(
160              action="patch",
161              name="with-ref",
162              old_string="Small content.",
163              new_string="x" * (MAX_SKILL_CONTENT_CHARS + 100),
164              file_path="references/data.md",
165          ))
166          assert result["success"] is False
167          assert "references/data.md" in result["error"]
168  
169  
170  class TestWriteFileSizeLimit:
171      """write_file action enforces both char and byte limits."""
172  
173      def test_write_file_over_char_limit(self, isolate_skills):
174          small = _make_skill_content(1000)
175          json.loads(skill_manage(action="create", name="file-test", content=small))
176  
177          result = json.loads(skill_manage(
178              action="write_file",
179              name="file-test",
180              file_path="references/huge.md",
181              file_content="x" * (MAX_SKILL_CONTENT_CHARS + 1),
182          ))
183          assert result["success"] is False
184          assert "100,000" in result["error"]
185  
186      def test_write_file_within_limit(self, isolate_skills):
187          small = _make_skill_content(1000)
188          json.loads(skill_manage(action="create", name="file-ok", content=small))
189  
190          result = json.loads(skill_manage(
191              action="write_file",
192              name="file-ok",
193              file_path="references/normal.md",
194              file_content="# Normal\n\n" + ("x" * 5000),
195          ))
196          assert result["success"] is True
197  
198  
199  class TestHandPlacedSkillsNoLimit:
200      """Skills dropped directly on disk are not constrained."""
201  
202      def test_oversized_handplaced_skill_loads(self, isolate_skills, tmp_path):
203          """A hand-placed 200k skill can still be read via skill_view."""
204          from tools.skills_tool import skill_view
205  
206          skill_dir = tmp_path / "skills" / "manual-giant"
207          skill_dir.mkdir(parents=True)
208          huge = _make_skill_content(200_000)
209          huge = huge.replace("name: test-skill", "name: manual-giant")
210          (skill_dir / "SKILL.md").write_text(huge, encoding="utf-8")
211  
212          result = json.loads(skill_view("manual-giant"))
213          assert "content" in result
214          # The full content is returned — no truncation at the storage layer
215          assert len(result["content"]) > MAX_SKILL_CONTENT_CHARS