GROUP, EXTRA_MODULES, MAX_SKILL_CHARS('pyskills', ('exhash.skill',), 20000)
Skills come from two sources. A pyskill is a Python module whose docstring is the skill, published by an installed package (see pyskills). A SKILL.md file is a markdown file in a skills folder, in the Agent Skills layout. Both become a Skill, with a name, a source, a description, and where it came from so a person can go and read it.
A Skill holds a loader for its body, not the body, so finding skills does not read every file. When no description is given, it is the body’s first paragraph on one line.
GROUP is the entry-point group installed packages publish pyskills under. EXTRA_MODULES lists modules that are skills but do not publish one. MAX_SKILL_CHARS caps the length of a skill body sent to the model.
One skill: its name, where it came from, when it applies, and a way to load its full text.
A one-line description from a skill body: its first paragraph, collapsed.
'Search the web and read results into durable memory.'
text() loads the body when it is asked for and clips it to MAX_SKILL_CHARS. A skill that cannot be read returns a message saying so, instead of raising an exception into the agent’s turn.
('the whole body', 'could not read skill gone: FileNotFoundError: SKILL.md')
Installed packages publish pyskills under the pyskills entry-point group. A module’s description comes from its docstring, and Python offers no way to read a docstring without importing the module, so discovery imports each pyskill module once. A module with an empty docstring is skipped.
A Skill for a module, without importing it until someone asks for the body.
exhash ships a skill module and is a dependency of shalya, so discovery always finds at least one real pyskill.
[('editskill', 'pyskill', 'fastcore.editskill'),
('rgapi', 'pyskill', 'rgapi.skill'),
('ghapi', 'pyskill', 'ghapi.skill'),
('kosha', 'pyskill', 'kosha.skill'),
('exhash', 'pyskill', 'exhash.skill'),
('skill', 'pyskill', 'nbdev.skill'),
('skill', 'pyskill', 'pyskills.skill'),
('skill', 'pyskill', 'llmsurgery.skill'),
('fastcdp', 'pyskill', 'fastcdp.skill'),
('dlgskill', 'pyskill', 'aidialog.dlgskill'),
('vishalakshi', 'pyskill', 'vishalakshi.skill'),
('coding_patterns', 'pyskill', 'ramabana.coding_patterns'),
('theory', 'pyskill', 'ramabana.theory'),
('write_docs', 'pyskill', 'ramabana.write_docs'),
('write_prose', 'pyskill', 'ramabana.write_prose')]
SKILL.md files are looked for in a list of folders, from lowest to highest precedence, so that a later folder’s skill replaces an earlier one with the same name. skill_dirs returns the list. It starts with the skills folder inside learned, for skills a tool wrote rather than a person, then the application’s config folder cfg, then ~/.agents/skills for a person’s own skills, then .leela/skills and .agents/skills in each project root. A project can therefore replace what a person set up for themselves, and a skill a tool learned replaces an installed pyskill but loses to every folder a person writes.
The read_skill tool reads the same installed skill that discovery found through the pyskills entry point:
installed_skills = discover()
installed_host = LocalHost(index=False)
installed_read = {t.__name__: t for t in skill_tools(installed_host, lambda: installed_skills)}['read_skill']
installed_exhash = next(s for s in installed_skills if s.where == 'exhash.skill')
installed_body = installed_read(installed_exhash.name)
assert installed_body.startswith(f'<skill name="{installed_exhash.name}"')
assert len(installed_body) > 100 and 'exhash' in installed_body.lower()Every skill available to the agent, sorted by name: pyskills, then skill_dirs, then extra, a later one replacing an earlier one of the same name.
Where SKILL.md files are looked for, in increasing precedence: learned first, then the user’s, then each project’s.
['/home/k/.config/leela/skills',
'/Users/71293/.agents/skills',
'/proj/.leela/skills',
'/proj/.agents/skills']
A skills folder follows the Agent Skills layout: one folder per skill holding a SKILL.md, whose optional frontmatter gives the name and description. Without frontmatter the folder name is the skill’s name and the first paragraph its description.
tmp = Path(tempfile.mkdtemp())
d = tmp/'.agents'/'skills'/'notebook-tests'
d.mkdir(parents=True)
(d/'SKILL.md').write_text('''---
name: notebook-tests
description: Write executable test cells in the notebook that owns the code.
---
Put the readable case in the notebook and the bulk assertions in `tests/`.
''')
plain = tmp/'.agents'/'skills'/'plain'
plain.mkdir()
(plain/'SKILL.md').write_text('Say what changed.\n\nAnd nothing else.\n')
[(s.name, s.description) for s in _md_skills(tmp/'.agents'/'skills')][('notebook-tests',
'Write executable test cells in the notebook that owns the code.'),
('plain', 'Say what changed.')]
got = {s.name: s for s in _md_skills(tmp/'.agents'/'skills')}
test_eq(sorted(got), ['notebook-tests', 'plain'])
test_eq(got['plain'].description, 'Say what changed.') # no frontmatter: the first paragraph
assert 'bulk assertions' in got['notebook-tests'].text()
test_eq(got['notebook-tests'].source, 'md')discover collects every source: pyskills, then the skill_dirs folders, then the extra skills you pass. A later source replaces an earlier skill of the same name, and the result is sorted by name.
['coding_patterns',
'design-taste-frontend',
'dlgskill',
'editskill',
'exhash',
'fastcdp',
'find-skills',
'full-output-enforcement',
'ghapi',
'hf-cli',
'high-end-visual-design',
'industrial-brutalist-ui',
'kosha',
'minimalist-ui',
'nbdev',
'notebook-tests',
'plain',
'redesign-existing-projects',
'rgapi',
'skill',
'stitch-design-taste',
'theory',
'vishalakshi',
'write_docs',
'write_prose']
names = [s.name for s in skills]
assert 'notebook-tests' in names and 'exhash' in names, names
test_eq(names, sorted(names))
mine = discover(roots=[tmp], extra=[Skill('plain', 'ext', 'Mine wins.')])
test_eq(next(s for s in mine if s.name == 'plain').description, 'Mine wins.')
learned = tmp/'learned'
for name, desc in [('notebook-tests', 'Learned version.'), ('triage', 'Only learned.')]:
(learned/'skills'/name).mkdir(parents=True)
(learned/'skills'/name/'SKILL.md').write_text(f'---\nname: {name}\ndescription: {desc}\n---\n\nbody\n')
got = {s.name: s.description for s in discover(roots=[tmp], learned=learned)}
test_eq(got['triage'], 'Only learned.')
test_eq(got['notebook-tests'], 'Write executable test cells in the notebook that owns the code.') # a person's folder outranks a learned oneskill_index renders the block that goes into the system prompt: a heading, a line telling the agent to call read_skill(name) before doing the work a skill covers, and one line per skill with its name and description, never its body. Each description is cut to SKILL_DESC_MAX characters at a word boundary, so one long description cannot crowd out the rest.
A skill by exact name, then unique prefix, then unique substring. Ambiguity is None, not a guess.
The system-prompt block listing each skill’s name and clipped description.
## Skills
Know-how available to you. Read one with `read_skill(name)` when its description matches what you are about to do, *before* you do it -- several of these describe tools already installed in this environment, so the code they discuss is also searchable with `search_code`.
- `notebook-tests` -- Write executable test cells in the notebook that owns the code.
find looks a skill up by exact name, then by unique prefix, then by unique substring. A name that matches several skills returns None instead of guessing, and the read_skill tool then lists the skills that exist.
('notebook-tests', 'notebook-tests', None)
test_eq(find(skills, 'notebook-tests').name, 'notebook-tests')
test_eq(find(skills, 'notebook').name, 'notebook-tests') # unique prefix
test_eq(find(skills, 'tests').name, 'notebook-tests') # unique substring
test_eq(find(skills, 'nope'), None)
two = [Skill('edit', 'md'), Skill('editor', 'md')]
test_eq(find(two, 'edit').name, 'edit') # exact beats prefix
test_eq(find(two, 'edi'), None) # ambiguous prefixAn extension is a Python file a user drops in an extensions folder. It defines setup(reg), and through reg, a Registry, it may add tools, skills and slash commands, hook lifecycle events, and replace the approval policy. That is all a Registry offers: it gives an extension no access to the model, the engine or the conversation history.
EVENTS lists the lifecycle events an extension may hook, such as before_tool and after_turn. Hooking any other name raises an error, so a typo cannot silently hook nothing.
Run each extension file and call its setup(reg), recording in reg.notes what each added or why it failed.
The extension folders to load: cfg/extensions, and with project=True each root’s .leela/extensions.
What an extension’s setup(reg) may add: tools, skills, commands, hooks and an approval policy.
Register the hooks a hooks.json lists: [{event, match, command}].
load(reg, roots, cfg) runs every .py file in the extension folders (ext_dirs) whose name does not start with _, plus any files or folders given in paths, and calls its setup(reg). It adds one line per file to reg.notes: what the file registered, or why it failed to load. It also registers the shell hooks listed in <cfg>/hooks.json (shell_hooks).
A hook may return a value. fire(event, ...) runs the event’s hooks in registration order and returns their return values, so a before_tool hook can return a reason to deny the call or replacement arguments, and an after_tool hook a rewritten result. A hook that raises is recorded in reg.notes and skipped. A shell hook is a command listed in hooks.json as {event, match, command}, where match is a glob on the tool name. It receives the event as JSON on standard input and may answer with a JSON object on standard output holding deny, args or result. Anything else it prints is ignored.
A real extension, with a real setup(reg), loaded from disk, alongside a file that fails to load and one with no setup:
extdir = tmp/'extensions'
extdir.mkdir()
(extdir/'wc.py').write_text('''
def setup(reg):
@reg.tool
def word_count(path: str) -> str:
"How many words are in a file."
return str(len(open(path).read().split()))
reg.command('wc', lambda agent, arg: 'counted ' + arg, help='count words')
reg.skill('house-style', 'Dense lines. Short names.', 'How code is written here.')
''')
(extdir/'helpers.py').write_text('SHARED = 1\n')
(extdir/'boom.py').write_text('raise RuntimeError("no")\n')
reg = load(Registry(), cfg=tmp)
reg.notes['boom.py: failed to load (RuntimeError: no)',
'helpers.py: loaded, no setup()',
'wc.py: 1 tool(s), 1 skill(s), 1 command(s)']
test_eq([t.__name__ for t in reg.tools], ['word_count'])
test_eq(list(reg.commands), ['wc'])
test_eq([s.name for s in reg.skills], ['house-style'])
test_eq(reg.skills[0].text(), 'Dense lines. Short names.')
test_eq(reg.tools[0](str(extdir/'helpers.py')), '3') # the tool really runs
assert any('helpers.py: loaded, no setup()' in n for n in reg.notes), reg.notes
assert any(n.startswith('boom.py: failed to load') for n in reg.notes), reg.notes(tmp/'hooks.json').write_text(json.dumps([
{'event': 'before_tool', 'match': 'run_*',
'command': "python3 -c 'import sys, json; d = json.load(sys.stdin); print(json.dumps({\"deny\": \"no rm\"}) if \"rm\" in d[\"args\"][2][\"command\"] else \"logged\")'"},
{'event': 'nonsense', 'command': 'true'}]))
reg = load(Registry(), cfg=tmp)
test_eq(reg.fire('before_tool', None, 'run_shell', {'command': 'rm -rf x'}), ['no rm'])
test_eq(reg.fire('before_tool', None, 'run_shell', {'command': 'ls'}), [None])
test_eq(reg.fire('before_tool', None, 'view_file', {}), [None])
assert any(n.endswith('1 hook(s)') or 'skipped' in n for n in reg.notes), reg.notes
test_eq(reg.drop_loaded().hooks['before_tool'], [])Reloading re-reads the extension files, and must not throw away what the running program registered since. load records which registrations came from the files (mark and mark_loaded), and drop_loaded removes exactly those and keeps the rest. loaded says whether the files are currently loaded.
test_eq([t.__name__ for t in live.tools], ['live_tool']) # the file's word_count went, mine stayed
test_eq(list(live.commands), ['mine'])
test_eq(len(live.hooks['after_tool']), 1)
test_eq([s.name for s in live.skills], [])
test_eq(live.loaded, False)
load(live, cfg=tmp) # the files come back on the next read
test_eq([t.__name__ for t in live.tools], ['live_tool', 'word_count'])
live.drop_loaded() # and a second round is not lossy
test_eq([t.__name__ for t in live.tools], ['live_tool'])
test_eq(Registry().drop_loaded().tools, []) # never loaded: nothing to take backHooking an unknown event raises an error:
A failing hook is recorded in notes and does not end the turn. Its result is left out of what fire returns.
(1,
[{'prompt': 'hello'}],
'before_turn hook failed: ZeroDivisionError: division by zero')
An extension may replace the approval policy, the function that decides whether a tool call goes ahead. The last one registered wins, and with none registered approve is None.
(<function __main__.approve_reads(tool, **kw)>, True, False)
Extension folders inside a project load only with project=True. Cloning a repository never runs its code.