Learn Claude Code
Back to Skills

SubagentsSkills

s04 (151 LOC) → s05 (187 LOC)

LOC Delta

+36lines

New Tools

1

load_skill
New Classes

1

SkillLoader
New Functions

0

Subagents

Clean Context Per Subtask

151 LOC

5 tools: bash, read_file, write_file, edit_file, task

planning

Skills

Load on Demand

187 LOC

5 tools: bash, read_file, write_file, edit_file, load_skill

planning

Source Code Diff

s04 (s04_subagent.py) -> s05 (s05_skill_loading.py)
11#!/usr/bin/env python3
2-# Harness: context isolation -- protecting the model's clarity of thought.
2+# Harness: on-demand knowledge -- domain expertise, loaded when the model asks.
33"""
4-s04_subagent.py - Subagents
4+s05_skill_loading.py - Skills
55
6-Spawn a child agent with fresh messages=[]. The child works in its own
7-context, sharing the filesystem, then returns only a summary to the parent.
6+Two-layer skill injection that avoids bloating the system prompt:
87
9- Parent agent Subagent
10- +------------------+ +------------------+
11- | messages=[...] | | messages=[] | <-- fresh
12- | | dispatch | |
13- | tool: task | ---------->| while tool_use: |
14- | prompt="..." | | call tools |
15- | description="" | | append results |
16- | | summary | |
17- | result = "..." | <--------- | return last text |
18- +------------------+ +------------------+
19- |
20- Parent context stays clean.
21- Subagent context is discarded.
8+ Layer 1 (cheap): skill names in system prompt (~100 tokens/skill)
9+ Layer 2 (on demand): full skill body in tool_result
2210
23-Key insight: "Process isolation gives context isolation for free."
11+ skills/
12+ pdf/
13+ SKILL.md <-- frontmatter (name, description) + body
14+ code-review/
15+ SKILL.md
16+
17+ System prompt:
18+ +--------------------------------------+
19+ | You are a coding agent. |
20+ | Skills available: |
21+ | - pdf: Process PDF files... | <-- Layer 1: metadata only
22+ | - code-review: Review code... |
23+ +--------------------------------------+
24+
25+ When model calls load_skill("pdf"):
26+ +--------------------------------------+
27+ | tool_result: |
28+ | <skill> |
29+ | Full PDF processing instructions | <-- Layer 2: full body
30+ | Step 1: ... |
31+ | Step 2: ... |
32+ | </skill> |
33+ +--------------------------------------+
34+
35+Key insight: "Don't put everything in the system prompt. Load on demand."
2436"""
2537
2638import os
39+import re
2740import subprocess
2841from pathlib import Path
2942
3043from anthropic import Anthropic
3144from dotenv import load_dotenv
3245
3346load_dotenv(override=True)
3447
3548if os.getenv("ANTHROPIC_BASE_URL"):
3649 os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
3750
3851WORKDIR = Path.cwd()
3952client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
4053MODEL = os.environ["MODEL_ID"]
54+SKILLS_DIR = WORKDIR / "skills"
4155
42-SYSTEM = f"You are a coding agent at {WORKDIR}. Use the task tool to delegate exploration or subtasks."
43-SUBAGENT_SYSTEM = f"You are a coding subagent at {WORKDIR}. Complete the given task, then summarize your findings."
4456
57+# -- SkillLoader: scan skills/<name>/SKILL.md with YAML frontmatter --
58+class SkillLoader:
59+ def __init__(self, skills_dir: Path):
60+ self.skills_dir = skills_dir
61+ self.skills = {}
62+ self._load_all()
4563
46-# -- Tool implementations shared by parent and child --
64+ def _load_all(self):
65+ if not self.skills_dir.exists():
66+ return
67+ for f in sorted(self.skills_dir.rglob("SKILL.md")):
68+ text = f.read_text()
69+ meta, body = self._parse_frontmatter(text)
70+ name = meta.get("name", f.parent.name)
71+ self.skills[name] = {"meta": meta, "body": body, "path": str(f)}
72+
73+ def _parse_frontmatter(self, text: str) -> tuple:
74+ """Parse YAML frontmatter between --- delimiters."""
75+ match = re.match(r"^---\n(.*?)\n---\n(.*)", text, re.DOTALL)
76+ if not match:
77+ return {}, text
78+ meta = {}
79+ for line in match.group(1).strip().splitlines():
80+ if ":" in line:
81+ key, val = line.split(":", 1)
82+ meta[key.strip()] = val.strip()
83+ return meta, match.group(2).strip()
84+
85+ def get_descriptions(self) -> str:
86+ """Layer 1: short descriptions for the system prompt."""
87+ if not self.skills:
88+ return "(no skills available)"
89+ lines = []
90+ for name, skill in self.skills.items():
91+ desc = skill["meta"].get("description", "No description")
92+ tags = skill["meta"].get("tags", "")
93+ line = f" - {name}: {desc}"
94+ if tags:
95+ line += f" [{tags}]"
96+ lines.append(line)
97+ return "\n".join(lines)
98+
99+ def get_content(self, name: str) -> str:
100+ """Layer 2: full skill body returned in tool_result."""
101+ skill = self.skills.get(name)
102+ if not skill:
103+ return f"Error: Unknown skill '{name}'. Available: {', '.join(self.skills.keys())}"
104+ return f"<skill name=\"{name}\">\n{skill['body']}\n</skill>"
105+
106+
107+SKILL_LOADER = SkillLoader(SKILLS_DIR)
108+
109+# Layer 1: skill metadata injected into system prompt
110+SYSTEM = f"""You are a coding agent at {WORKDIR}.
111+Use load_skill to access specialized knowledge before tackling unfamiliar topics.
112+
113+Skills available:
114+{SKILL_LOADER.get_descriptions()}"""
115+
116+
117+# -- Tool implementations --
47118def safe_path(p: str) -> Path:
48119 path = (WORKDIR / p).resolve()
49120 if not path.is_relative_to(WORKDIR):
50121 raise ValueError(f"Path escapes workspace: {p}")
51122 return path
52123
53124def run_bash(command: str) -> str:
54125 dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
55126 if any(d in command for d in dangerous):
56127 return "Error: Dangerous command blocked"
57128 try:
58129 r = subprocess.run(command, shell=True, cwd=WORKDIR,
59130 capture_output=True, text=True, timeout=120)
60131 out = (r.stdout + r.stderr).strip()
61132 return out[:50000] if out else "(no output)"
62133 except subprocess.TimeoutExpired:
63134 return "Error: Timeout (120s)"
64135
65136def run_read(path: str, limit: int = None) -> str:
66137 try:
67138 lines = safe_path(path).read_text().splitlines()
68139 if limit and limit < len(lines):
69140 lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
70141 return "\n".join(lines)[:50000]
71142 except Exception as e:
72143 return f"Error: {e}"
73144
74145def run_write(path: str, content: str) -> str:
75146 try:
76147 fp = safe_path(path)
77148 fp.parent.mkdir(parents=True, exist_ok=True)
78149 fp.write_text(content)
79150 return f"Wrote {len(content)} bytes"
80151 except Exception as e:
81152 return f"Error: {e}"
82153
83154def run_edit(path: str, old_text: str, new_text: str) -> str:
84155 try:
85156 fp = safe_path(path)
86157 content = fp.read_text()
87158 if old_text not in content:
88159 return f"Error: Text not found in {path}"
89160 fp.write_text(content.replace(old_text, new_text, 1))
90161 return f"Edited {path}"
91162 except Exception as e:
92163 return f"Error: {e}"
93164
94165
95166TOOL_HANDLERS = {
96167 "bash": lambda **kw: run_bash(kw["command"]),
97168 "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
98169 "write_file": lambda **kw: run_write(kw["path"], kw["content"]),
99170 "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
171+ "load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]),
100172}
101173
102-# Child gets all base tools except task (no recursive spawning)
103-CHILD_TOOLS = [
174+TOOLS = [
104175 {"name": "bash", "description": "Run a shell command.",
105176 "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
106177 {"name": "read_file", "description": "Read file contents.",
107178 "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
108179 {"name": "write_file", "description": "Write content to file.",
109180 "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
110181 {"name": "edit_file", "description": "Replace exact text in file.",
111182 "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
183+ {"name": "load_skill", "description": "Load specialized knowledge by name.",
184+ "input_schema": {"type": "object", "properties": {"name": {"type": "string", "description": "Skill name to load"}}, "required": ["name"]}},
112185]
113186
114187
115-# -- Subagent: fresh context, filtered tools, summary-only return --
116-def run_subagent(prompt: str) -> str:
117- sub_messages = [{"role": "user", "content": prompt}] # fresh context
118- for _ in range(30): # safety limit
119- response = client.messages.create(
120- model=MODEL, system=SUBAGENT_SYSTEM, messages=sub_messages,
121- tools=CHILD_TOOLS, max_tokens=8000,
122- )
123- sub_messages.append({"role": "assistant", "content": response.content})
124- if response.stop_reason != "tool_use":
125- break
126- results = []
127- for block in response.content:
128- if block.type == "tool_use":
129- handler = TOOL_HANDLERS.get(block.name)
130- output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
131- results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)[:50000]})
132- sub_messages.append({"role": "user", "content": results})
133- # Only the final text returns to the parent -- child context is discarded
134- return "".join(b.text for b in response.content if hasattr(b, "text")) or "(no summary)"
135-
136-
137-# -- Parent tools: base tools + task dispatcher --
138-PARENT_TOOLS = CHILD_TOOLS + [
139- {"name": "task", "description": "Spawn a subagent with fresh context. It shares the filesystem but not conversation history.",
140- "input_schema": {"type": "object", "properties": {"prompt": {"type": "string"}, "description": {"type": "string", "description": "Short description of the task"}}, "required": ["prompt"]}},
141-]
142-
143-
144188def agent_loop(messages: list):
145189 while True:
146190 response = client.messages.create(
147191 model=MODEL, system=SYSTEM, messages=messages,
148- tools=PARENT_TOOLS, max_tokens=8000,
192+ tools=TOOLS, max_tokens=8000,
149193 )
150194 messages.append({"role": "assistant", "content": response.content})
151195 if response.stop_reason != "tool_use":
152196 return
153197 results = []
154198 for block in response.content:
155199 if block.type == "tool_use":
156- if block.name == "task":
157- desc = block.input.get("description", "subtask")
158- print(f"> task ({desc}): {block.input['prompt'][:80]}")
159- output = run_subagent(block.input["prompt"])
160- else:
161- handler = TOOL_HANDLERS.get(block.name)
200+ handler = TOOL_HANDLERS.get(block.name)
201+ try:
162202 output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
163- print(f" {str(output)[:200]}")
203+ except Exception as e:
204+ output = f"Error: {e}"
205+ print(f"> {block.name}: {str(output)[:200]}")
164206 results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
165207 messages.append({"role": "user", "content": results})
166208
167209
168210if __name__ == "__main__":
169211 history = []
170212 while True:
171213 try:
172- query = input("\033[36ms04 >> \033[0m")
214+ query = input("\033[36ms05 >> \033[0m")
173215 except (EOFError, KeyboardInterrupt):
174216 break
175217 if query.strip().lower() in ("q", "exit", ""):
176218 break
177219 history.append({"role": "user", "content": query})
178220 agent_loop(history)
179221 response_content = history[-1]["content"]
180222 if isinstance(response_content, list):
181223 for block in response_content:
182224 if hasattr(block, "text"):
183225 print(block.text)
184226 print()