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