Skip to content

Add support for Agent Skills#16

Merged
MaryamZi merged 18 commits intomainfrom
agent-skills
Mar 23, 2026
Merged

Add support for Agent Skills#16
MaryamZi merged 18 commits intomainfrom
agent-skills

Conversation

@MaryamZi
Copy link
Copy Markdown
Member

@MaryamZi MaryamZi commented Mar 20, 2026

Purpose

$title.

PR from feature branch with #13 and #15.

Summary by CodeRabbit

  • New Features

    • Local skills system: discoverable skills, activate skills, and read bundled skill resources from agents.
  • Improvements

    • AFM metadata is optional; agent startup and parsing are more null-safe.
    • Discovered skills are surfaced in agent prompts and added as callable tools.
  • Documentation

    • Docker example for mounting local skills and project layout updated; AFM template now supports a local skills block.
  • Chores

    • Runtime and package versions updated.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 20, 2026

Walkthrough

Adds local "skills" support across Ballerina and Python interpreters: new skill discovery/parsing, catalog generation, secure resource access, AFM metadata/extensions, parser frontmatter extraction, agent/toolkit integration, LangChain tools, tests/fixtures, and dependency/template updates.

Changes

Cohort / File(s) Summary
Dependency & Template
ballerina-interpreter/Dependencies.toml, templates/agent_template.afm.md
Bumped distribution and package versions; added ballerina:file module dependency; added skills local-source example to AFM template.
Ballerina Types & Signatures
ballerina-interpreter/types.bal, ballerina-interpreter/agent.bal, ballerina-interpreter/main.bal, ballerina-interpreter/web_chat_ui.bal
Added skill-related types; made AFMRecord.metadata optional; createAgent/runAgentFromAFM now accept AFM file directory; web UI and agent init accept optional metadata.
Ballerina Parser & Frontmatter
ballerina-interpreter/parser.bal
Factored frontmatter extraction into extractFrontMatter, conditional frontmatter parsing, return typed metadata and body, updated HTTP-variable validation to inspect skills.
Ballerina Skill Implementation
ballerina-interpreter/skills.bal, ballerina-interpreter/README.md
New skills module: discovery (local paths), SKILL.md parsing/validation, catalog builder (names/descriptions), SkillsToolKit with activateSkill and readSkillResource, and README examples for mounting local skills.
Ballerina Tests & Fixtures
ballerina-interpreter/tests/*, ballerina-interpreter/tests/skills/*/SKILL.md
Updated tests to pass AFM dir; added comprehensive skills tests and many SKILL.md fixtures (valid, invalid, duplicates, resources, progressive disclosure).
Python Models & Parser
python-interpreter/packages/afm-core/src/afm/models.py, .../parser.py
Added Pydantic models LocalSkillSource/SkillInfo, optional skills and source_dir, added extract_raw_frontmatter, set source_dir when parsing AFM files.
Python Skills Module
python-interpreter/packages/afm-core/src/afm/skills.py
New module for discover/parse/catalog/activate/read skills with traversal protection, resource listing, and catalog generation.
Python LangChain Integration
python-interpreter/packages/afm-langchain/src/afm_langchain/backend.py, .../tools/skills.py
LangChain runner now extracts skill catalog and injects activate_skill and read_skill_resource tools; added tool wrappers with input schemas and error handling.
Python Tests & Fixtures
python-interpreter/packages/afm-core/tests/*, python-interpreter/packages/afm-langchain/tests/test_langchain_skills.py, .../fixtures/skills/*/SKILL.md
Added unit/integration tests and fixtures covering parsing, discovery, cataloging, activation, resource reads, duplicate handling, and symlink traversal protection.

Sequence Diagram(s)

sequenceDiagram
    participant Agent as Agent
    participant SkillsMgr as Skills Manager
    participant FS as File System
    participant Catalog as Skill Catalog
    participant Toolkit as Skills Toolkit

    Agent->>SkillsMgr: extractSkillCatalog(metadata, afmFileDir)
    SkillsMgr->>FS: resolve skill source paths
    SkillsMgr->>FS: read SKILL.md files
    FS-->>SkillsMgr: frontmatter + body
    SkillsMgr->>SkillsMgr: validate & collect SkillInfo (resources)
    SkillsMgr->>Catalog: build catalog text (names/descriptions)
    Catalog-->>Agent: catalog text
    Agent->>Toolkit: activateSkill(skillName)
    Toolkit->>Toolkit: validate skill existence
    Toolkit->>FS: read referenced resource file
    FS-->>Toolkit: file content
    Toolkit-->>Agent: skill body (+ resources section or error)
Loading
sequenceDiagram
    participant User as AFM File / User
    participant Parser as Parser
    participant FM as Frontmatter Handler
    participant Validator as Validator

    User->>Parser: parseAfm(content)
    Parser->>FM: extractFrontMatter(content)
    FM->>FM: detect '---' delimiters, parse YAML
    FM-->>Parser: (metadata map, body)
    Parser->>Validator: validateHttpVariables & skills paths
    Validator-->>Parser: validation result
    Parser-->>User: AFMRecord (metadata optional, skills optional)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • RadCod3

Poem

🐰 Hopping through folders, I sniff and find,

SKILL.md seeds with frontmatter signed,
Catalogs whisper names, not the whole tale,
Activate reveals body and resource trail,
Safe paths only — no sly .. shall prevail.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is largely incomplete, providing only minimal purpose statement without addressing most required template sections (Goals, Approach, User stories, Release note, Documentation, Training, Certification, Marketing, Automation tests, Security checks, etc.). Complete the PR description by adding Goals, Approach, User stories, Release note, Documentation, Training, Certification, Marketing, Automation tests (with coverage details), Security checks, Samples, Test environment, and Learning sections as specified in the repository template.
Docstring Coverage ⚠️ Warning Docstring coverage is 14.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'Add support for Agent Skills' accurately and concisely summarizes the main change: introducing agent skills functionality across both Ballerina and Python interpreters.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent-skills
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (2)
ballerina-interpreter/tests/agent_test.bal (1)

372-372: Use the AFM’s real parent directory in these test startups.

Lines 372 and 399 hardcode "tests", while production resolves afmFileDir from the AFM file’s absolute parent path first. That keeps these tests coupled to the runner’s current working directory and stops them from covering the same relative-skill resolution behavior used in main.bal.

Also applies to: 399-399

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ballerina-interpreter/tests/agent_test.bal` at line 372, The tests call
runAgentFromAFM(afm, testPort, "tests") with a hardcoded "tests" path which
diverges from production behavior; change the third argument to use the AFM
file's real parent directory (derive it from the afm file object, e.g.,
afm.parent() or afmFileDir resolved from afm.absolutePath) so the test uses the
AFM’s actual parent folder for relative-skill resolution; update both
occurrences (the call at runAgentFromAFM and the similar call around line 399)
to pass the computed parent directory instead of the string "tests".
ballerina-interpreter/tests/skills_test.bal (1)

582-586: Strengthen getTools() assertion to avoid false positives.

Line 585 only checks count. This can pass even if wrong tools are returned. Assert expected tool names (e.g., activateSkill, readSkillResource) too.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ballerina-interpreter/tests/skills_test.bal` around lines 582 - 586, The
current test only checks tools.length() after calling toolkit.getTools(), which
can yield false positives; modify the test that uses getTools() (variable tools)
to also assert the returned tools include the expected names "activateSkill" and
"readSkillResource" (e.g., by iterating tools and asserting each tool's name
property or getName() value or by collecting names into a list/set and comparing
to the expected set), rather than relying solely on tools.length().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ballerina-interpreter/parser.bal`:
- Around line 25-31: The current check uses
resolvedContent.startsWith(FRONTMATTER_DELIMITER) which incorrectly treats any
string beginning with '---' as frontmatter; change the guard to only treat
frontmatter when the very first line equals the delimiter. Concretely, extract
the first line from resolvedContent (up to the first '\n' or end-of-string) and
test that firstLine.trim() == FRONTMATTER_DELIMITER before calling
extractFrontMatter; keep the rest of the logic (frontmatterMap, body, metadata
assignments) unchanged and reference FRONTMATTER_DELIMITER, resolvedContent,
extractFrontMatter, metadata, and body when making the update.

In `@ballerina-interpreter/skills.bal`:
- Around line 127-145: listLocalResources currently trusts directory entries and
can follow symlinks outside the skill tree; canonicalize and validate paths
before adding/reading them by resolving both the directory and each entry to
their absolute/canonical forms (e.g., via file:resolvePath or equivalent) and
ensure the resolved entry path is contained within the resolved base directory
for that resource (use string startsWith or path comparison) before pushing into
resources; apply the same canonicalization+containment check in
readSkillResource so symlinks cannot escape the skill tree (refer to functions
listLocalResources and readSkillResource and the variables
dirPath/entry.absPath).
- Around line 217-238: readSkillResource currently only verifies the resource
exists but does not verify the skill was previously activated, allowing bypass
of progressive disclosure; after obtaining SkillInfo (the `info` variable) add a
guard that the skill is in the activated state (use the existing activation
tracking used by `activateSkill` — e.g. `info.active` or
`self.activeSkills`/`self.activatedSkills`) and return an error like "Skill not
activated" if it isn't; ensure the new check runs before returning the resource
and mirror the same activation-check semantics used by `activateSkill`.

In
`@ballerina-interpreter/tests/skills/duplicate_names/error_translator_dup/SKILL.md`:
- Around line 6-8: The discovery loop in skills.bal uses file:readDir() which
returns unsorted entries causing
testDiscoverLocalSkillsDuplicateSubdirectories() to be order-fragile; fix by
sorting the directory entries by their path/name before iterating (e.g., collect
entries into a list and call a sort by entry.path or entry.name) so the
first-occurrence tie-breaker is deterministic and the expected "alphabetically
first" skill (error_translator) always wins.

In `@python-interpreter/packages/afm-core/src/afm/models.py`:
- Around line 210-217: SkillInfo.base_path and AFMRecord.source_dir are
runtime-only filesystem paths that must not be serialized; update their
declarations to exclude them from model dumps/JSON. For both SkillInfo.base_path
and the AFMRecord.source_dir field (used/assigned by parse_afm_file()), mark the
fields as non-serializable (e.g., use Field(..., exclude=True) or convert them
to PrivateAttr if you want them to be non-model attributes) so that
model_dump()/json() won’t expose host filesystem paths; ensure tests and any
code referencing these attributes still access them at runtime but serialization
omits them.

In `@python-interpreter/packages/afm-core/src/afm/skills.py`:
- Around line 163-173: list_local_resources advertises every file under
REFERENCES_DIR and ASSETS_DIR including binaries that read_skill_resource
decodes as UTF-8 (which can raise UnicodeDecodeError) and the
ReadSkillResourceTool only catches ValueError; update read_skill_resource (and
any callers) to catch UnicodeDecodeError and re-raise as ValueError with a clear
message, and enforce a maximum file-size limit (e.g., reject/raise ValueError if
size > threshold) before returning contents so large assets aren’t served; also
consider updating ReadSkillResourceTool to rely on ValueError only so behavior
is consistent with list_local_resources' advertised files.
- Around line 176-199: The XML-style prompt blocks in build_skill_catalog are
injecting unescaped skill fields (SkillInfo.name, SkillInfo.description and any
resource names) directly into tags and attributes (<available_skills>, <skill>,
<name>, <description>, and the name="..." attribute used with activate_skill),
so update build_skill_catalog to escape or validate those values before
formatting (use a single util like xml_escape or html.escape to replace &, <, >,
" and '). Also apply the same escaping/validation to the other prompt-building
code that interpolates <file> elements and name="..." attributes so no
user/FS-provided string can break the wrapper or change prompt structure.

In `@python-interpreter/packages/afm-langchain/src/afm_langchain/tools/skills.py`:
- Around line 42-80: The tools currently allow reading any skill resource
because ActivateSkillTool does not record activations and ReadSkillResourceTool
uses the full skills map; add a shared per-runner set (e.g., activated_skills:
set[str]) accessible to both ActivateSkillTool and ReadSkillResourceTool, update
ActivateSkillTool._run to add the skill name into activated_skills after a
successful activate_skill(...) call, and modify ReadSkillResourceTool._run to
check the skill_name is present in activated_skills and raise/return an error if
not activated before calling read_skill_resource(...); reference the classes
ActivateSkillTool and ReadSkillResourceTool and functions activate_skill and
read_skill_resource when making the changes.

---

Nitpick comments:
In `@ballerina-interpreter/tests/agent_test.bal`:
- Line 372: The tests call runAgentFromAFM(afm, testPort, "tests") with a
hardcoded "tests" path which diverges from production behavior; change the third
argument to use the AFM file's real parent directory (derive it from the afm
file object, e.g., afm.parent() or afmFileDir resolved from afm.absolutePath) so
the test uses the AFM’s actual parent folder for relative-skill resolution;
update both occurrences (the call at runAgentFromAFM and the similar call around
line 399) to pass the computed parent directory instead of the string "tests".

In `@ballerina-interpreter/tests/skills_test.bal`:
- Around line 582-586: The current test only checks tools.length() after calling
toolkit.getTools(), which can yield false positives; modify the test that uses
getTools() (variable tools) to also assert the returned tools include the
expected names "activateSkill" and "readSkillResource" (e.g., by iterating tools
and asserting each tool's name property or getName() value or by collecting
names into a list/set and comparing to the expected set), rather than relying
solely on tools.length().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2d5a48f3-67f9-44a3-9b80-29ffa1f1f456

📥 Commits

Reviewing files that changed from the base of the PR and between 1493056 and ba1ff9e.

📒 Files selected for processing (41)
  • ballerina-interpreter/Dependencies.toml
  • ballerina-interpreter/README.md
  • ballerina-interpreter/agent.bal
  • ballerina-interpreter/main.bal
  • ballerina-interpreter/parser.bal
  • ballerina-interpreter/skills.bal
  • ballerina-interpreter/tests/agent_test.bal
  • ballerina-interpreter/tests/main_test.bal
  • ballerina-interpreter/tests/skills/duplicate_names/error_translator/SKILL.md
  • ballerina-interpreter/tests/skills/duplicate_names/error_translator_dup/SKILL.md
  • ballerina-interpreter/tests/skills/empty_dir/.gitkeep
  • ballerina-interpreter/tests/skills/invalid_skill/SKILL.md
  • ballerina-interpreter/tests/skills/multi_skills/pr_summary/SKILL.md
  • ballerina-interpreter/tests/skills/multi_skills/security_review/SKILL.md
  • ballerina-interpreter/tests/skills/multi_skills/security_review/assets/template.json
  • ballerina-interpreter/tests/skills/multi_skills/security_review/references/REFERENCE.md
  • ballerina-interpreter/tests/skills/no_frontmatter/SKILL.md
  • ballerina-interpreter/tests/skills/partial_invalid/invalid_skill/SKILL.md
  • ballerina-interpreter/tests/skills/partial_invalid/valid_skill/SKILL.md
  • ballerina-interpreter/tests/skills/single_skill/SKILL.md
  • ballerina-interpreter/tests/skills/source1/release_notes/SKILL.md
  • ballerina-interpreter/tests/skills/source2/release_notes/SKILL.md
  • ballerina-interpreter/tests/skills_test.bal
  • ballerina-interpreter/types.bal
  • ballerina-interpreter/web_chat_ui.bal
  • python-interpreter/packages/afm-core/src/afm/models.py
  • python-interpreter/packages/afm-core/src/afm/parser.py
  • python-interpreter/packages/afm-core/src/afm/skills.py
  • python-interpreter/packages/afm-core/tests/fixtures/skills/empty_dir/.gitkeep
  • python-interpreter/packages/afm-core/tests/fixtures/skills/invalid_skill/SKILL.md
  • python-interpreter/packages/afm-core/tests/fixtures/skills/multi_skills/pr_summary/SKILL.md
  • python-interpreter/packages/afm-core/tests/fixtures/skills/multi_skills/security_review/SKILL.md
  • python-interpreter/packages/afm-core/tests/fixtures/skills/multi_skills/security_review/assets/template.json
  • python-interpreter/packages/afm-core/tests/fixtures/skills/multi_skills/security_review/references/REFERENCE.md
  • python-interpreter/packages/afm-core/tests/fixtures/skills/no_frontmatter/SKILL.md
  • python-interpreter/packages/afm-core/tests/fixtures/skills/single_skill/SKILL.md
  • python-interpreter/packages/afm-core/tests/test_skills.py
  • python-interpreter/packages/afm-langchain/src/afm_langchain/backend.py
  • python-interpreter/packages/afm-langchain/src/afm_langchain/tools/skills.py
  • python-interpreter/packages/afm-langchain/tests/test_langchain_skills.py
  • templates/agent_template.afm.md

Comment on lines +127 to +145
function listLocalResources(string basePath) returns string[] {
string[] resources = [];
foreach string dir in [REFERENCES_DIR, ASSETS_DIR] {
do {
string dirPath = check file:joinPath(basePath, dir);
if !check file:test(dirPath, file:EXISTS) {
continue;
}
file:MetaData[] entries = check file:readDir(dirPath);
foreach file:MetaData entry in entries {
if !entry.dir {
resources.push(string `${dir}/${check file:basename(entry.absPath)}`);
}
}
} on fail error e {
log:printWarn(string `Failed to read directory ${dir}`, 'error = e);
}
}
return resources;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Canonicalize resource paths before reading them.

Lines 127-145 whitelist every non-directory entry under references/ and assets/, and Lines 237-238 read that path after only a textual .. check. A symlink inside one of those directories can still point outside the skill tree and expose arbitrary host files through readSkillResource, which defeats the path-traversal guard.

Also applies to: 237-238

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ballerina-interpreter/skills.bal` around lines 127 - 145, listLocalResources
currently trusts directory entries and can follow symlinks outside the skill
tree; canonicalize and validate paths before adding/reading them by resolving
both the directory and each entry to their absolute/canonical forms (e.g., via
file:resolvePath or equivalent) and ensure the resolved entry path is contained
within the resolved base directory for that resource (use string startsWith or
path comparison) before pushing into resources; apply the same
canonicalization+containment check in readSkillResource so symlinks cannot
escape the skill tree (refer to functions listLocalResources and
readSkillResource and the variables dirPath/entry.absPath).

Comment on lines +217 to +238
isolated function readSkillResource(string skillName, string resourcePath) returns string|error {
if !self.skills.hasKey(skillName) {
return error(string `Skill '${skillName}' not found`);
}

string[] segments = check file:splitPath(resourcePath);
if segments.length() < 2 || (segments[0] != REFERENCES_DIR && segments[0] != ASSETS_DIR) {
return error(string `Resource path must start with '${REFERENCES_DIR}/' or '${ASSETS_DIR}/'`);
}

if segments.indexOf("..") != () {
return error("Path traversal is not allowed in resource paths");
}

SkillInfo info = self.skills.get(skillName);
if info.resources.indexOf(resourcePath) is () {
return error(string `Resource '${resourcePath}' not found in skill '${
skillName}'. Available: ${string:'join(", ", ...info.resources)}`);
}

string fullPath = check file:joinPath(info.basePath, resourcePath);
return io:fileReadString(fullPath);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

readSkillResource can be called before activateSkill.

Line 232 only checks that the resource was discovered for the skill. There is no state tying readSkillResource to a prior activateSkill call, so a model can bypass the intended progressive-disclosure step by guessing a conventional filename like references/REFERENCE.md.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ballerina-interpreter/skills.bal` around lines 217 - 238, readSkillResource
currently only verifies the resource exists but does not verify the skill was
previously activated, allowing bypass of progressive disclosure; after obtaining
SkillInfo (the `info` variable) add a guard that the skill is in the activated
state (use the existing activation tracking used by `activateSkill` — e.g.
`info.active` or `self.activeSkills`/`self.activatedSkills`) and return an error
like "Skill not activated" if it isn't; ensure the new check runs before
returning the resource and mirror the same activation-check semantics used by
`activateSkill`.

Comment on lines +176 to +199
def build_skill_catalog(skills: dict[str, SkillInfo]) -> str | None:
"""Build a skill catalog string for inclusion in the system prompt."""
if not skills:
return None

skill_entries = "\n".join(
f" <skill>\n"
f" <name>{info.name}</name>\n"
f" <description>{info.description}</description>\n"
f" </skill>"
for info in skills.values()
)

return (
"\n## Available Skills\n"
"\n"
"The following skills provide specialized instructions for specific tasks.\n"
"When a task matches a skill's description, call the activate_skill tool\n"
"with the skill's name to load its full instructions.\n"
"\n"
"<available_skills>\n"
f"{skill_entries}\n"
"</available_skills>\n"
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Escape metadata before embedding it in the XML-style prompt blocks.

Skill names, descriptions, and resource names come from SKILL.md and the filesystem, but they are interpolated verbatim into <available_skills>, <file>, and the name="..." attribute. A <, &, or quote in those values breaks the wrapper and can change the prompt structure. Please escape those fields, or validate them against a safe character set before formatting.

Also applies to: 202-225

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@python-interpreter/packages/afm-core/src/afm/skills.py` around lines 176 -
199, The XML-style prompt blocks in build_skill_catalog are injecting unescaped
skill fields (SkillInfo.name, SkillInfo.description and any resource names)
directly into tags and attributes (<available_skills>, <skill>, <name>,
<description>, and the name="..." attribute used with activate_skill), so update
build_skill_catalog to escape or validate those values before formatting (use a
single util like xml_escape or html.escape to replace &, <, >, " and '). Also
apply the same escaping/validation to the other prompt-building code that
interpolates <file> elements and name="..." attributes so no user/FS-provided
string can break the wrapper or change prompt structure.

Comment on lines +42 to +80
class ActivateSkillTool(BaseTool):
"""Activates a skill by name and returns its full instructions."""

name: str = "activate_skill"
description: str = (
"Activates a skill by name and returns its full instructions "
"along with available resources. Call this when a task matches "
"one of the available skills' descriptions."
)
args_schema: Type[BaseModel] = ActivateSkillInput

skills: dict[str, SkillInfo]

def _run(self, name: str, **kwargs: Any) -> str:
try:
return activate_skill(name, self.skills)
except ValueError as e:
return f"Error: {e}"


class ReadSkillResourceTool(BaseTool):
"""Reads a resource file from a skill's references/ or assets/ directory."""

name: str = "read_skill_resource"
description: str = (
"Reads a resource file from a skill's references/ or assets/ directory. "
"Only files listed in skill_resources after activating a skill can be read."
)
args_schema: Type[BaseModel] = ReadSkillResourceInput

skills: dict[str, SkillInfo]

def _run(
self, skill_name: str, resource_path: str, **kwargs: Any
) -> str:
try:
return read_skill_resource(skill_name, resource_path, self.skills)
except ValueError as e:
return f"Error: {e}"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Track activation state before allowing resource reads.

ActivateSkillTool never records which skills were activated, and ReadSkillResourceTool gets the full skills map up front, so any listed resource can be read directly without calling activate_skill first. That breaks the progressive-disclosure contract described in the tool docs/system prompt. Please share a per-runner activated_skills set between these tools and reject reads until the matching skill was successfully activated.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@python-interpreter/packages/afm-langchain/src/afm_langchain/tools/skills.py`
around lines 42 - 80, The tools currently allow reading any skill resource
because ActivateSkillTool does not record activations and ReadSkillResourceTool
uses the full skills map; add a shared per-runner set (e.g., activated_skills:
set[str]) accessible to both ActivateSkillTool and ReadSkillResourceTool, update
ActivateSkillTool._run to add the skill name into activated_skills after a
successful activate_skill(...) call, and modify ReadSkillResourceTool._run to
check the skill_name is present in activated_skills and raise/return an error if
not activated before calling read_skill_resource(...); reference the classes
ActivateSkillTool and ReadSkillResourceTool and functions activate_skill and
read_skill_resource when making the changes.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@python-interpreter/packages/afm-core/src/afm/skills.py`:
- Around line 89-93: When detecting a single-skill file (the skill_md_path /
SKILL_FILE branch) wrap the parse_skill_md(skill_md_path, path) call in a
try/except ValueError block (mirroring the subdirectory handling at lines
107-111), log the parsing error with the same logger/message pattern used for
subdirectories, and on failure return an empty dict instead of letting the
ValueError propagate; this ensures parse_skill_md, SKILL_FILE, and skill_md_path
are handled consistently with the subdirectory parsing logic.

In `@python-interpreter/packages/afm-langchain/src/afm_langchain/tools/skills.py`:
- Around line 72-76: The _run method currently only catches ValueError but
read_skill_resource can raise UnicodeDecodeError when binary files are read;
update error handling either by adding an except UnicodeDecodeError as e branch
in _run (method _run in the class defined in skills.py) to return a clear error
string (e.g., "Error: {e}"), or modify read_skill_resource in afm/skills.py to
catch UnicodeDecodeError and re-raise it as a ValueError (raise ValueError(...)
from e) so the existing _run except ValueError path continues to work; choose
one of these fixes and apply consistently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 17cff781-729b-4a2f-bf09-5fc1ab472c65

📥 Commits

Reviewing files that changed from the base of the PR and between ba1ff9e and eaa5ba0.

📒 Files selected for processing (4)
  • python-interpreter/packages/afm-core/src/afm/skills.py
  • python-interpreter/packages/afm-core/tests/test_skills.py
  • python-interpreter/packages/afm-langchain/src/afm_langchain/tools/skills.py
  • python-interpreter/packages/afm-langchain/tests/test_langchain_skills.py
✅ Files skipped from review due to trivial changes (2)
  • python-interpreter/packages/afm-langchain/tests/test_langchain_skills.py
  • python-interpreter/packages/afm-core/tests/test_skills.py

Comment on lines +89 to +93
# Check if this path itself is a skill
skill_md_path = path / SKILL_FILE
if skill_md_path.is_file():
info = parse_skill_md(skill_md_path, path)
return {info.name: info}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent error handling for single-skill vs. subdirectory parsing.

When path/SKILL.md exists directly (single-skill case), parse_skill_md is called without a try-except, so a ValueError from malformed frontmatter will propagate up. In contrast, subdirectory skills (lines 107-111) catch ValueError and log an error, continuing gracefully. This inconsistency may cause unexpected crashes for invalid single-skill directories.

🛡️ Proposed fix to add consistent error handling
     # Check if this path itself is a skill
     skill_md_path = path / SKILL_FILE
     if skill_md_path.is_file():
-        info = parse_skill_md(skill_md_path, path)
-        return {info.name: info}
+        try:
+            info = parse_skill_md(skill_md_path, path)
+            return {info.name: info}
+        except ValueError as e:
+            logger.error("Failed to parse skill at %s: %s", path, e)
+            return {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@python-interpreter/packages/afm-core/src/afm/skills.py` around lines 89 - 93,
When detecting a single-skill file (the skill_md_path / SKILL_FILE branch) wrap
the parse_skill_md(skill_md_path, path) call in a try/except ValueError block
(mirroring the subdirectory handling at lines 107-111), log the parsing error
with the same logger/message pattern used for subdirectories, and on failure
return an empty dict instead of letting the ValueError propagate; this ensures
parse_skill_md, SKILL_FILE, and skill_md_path are handled consistently with the
subdirectory parsing logic.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (4)
python-interpreter/packages/afm-core/src/afm/skills.py (3)

162-172: ⚠️ Potential issue | 🟠 Major

Reject oversized resources before returning them to the model.

list_local_resources() advertises every file, and read_skill_resource() reads the entire file into memory and returns it verbatim. A large asset will go straight into the tool response, which can spike latency and context usage very quickly. Enforce a maximum size before read_text(), and ideally stop listing files above that threshold so the catalog matches what the tool can actually serve.

Possible low-impact fix
 logger = logging.getLogger(__name__)
 SKILL_FILE = "SKILL.md"
 REFERENCES_DIR = "references"
 ASSETS_DIR = "assets"
+MAX_RESOURCE_BYTES = 64 * 1024
 ...
     full_path = (info.base_path / resource_path).resolve()
     if not full_path.is_relative_to(info.base_path.resolve()):
         raise ValueError("Path traversal is not allowed in resource paths")
 
+    size = full_path.stat().st_size
+    if size > MAX_RESOURCE_BYTES:
+        raise ValueError(
+            f"Resource '{resource_path}' exceeds the {MAX_RESOURCE_BYTES}-byte limit"
+        )
+
     try:
         return full_path.read_text(encoding="utf-8")
     except UnicodeDecodeError:
         raise ValueError(
             f"Resource '{resource_path}' is a binary file and cannot be read as text"

Also applies to: 224-258

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@python-interpreter/packages/afm-core/src/afm/skills.py` around lines 162 -
172, list_local_resources currently advertises every file even if too large for
read_skill_resource; add a MAX_RESOURCE_SIZE_BYTES constant and use
entry.stat().st_size to skip adding any file whose size exceeds that threshold
so the catalog matches what can be served, and update read_skill_resource to
check Path.stat().st_size before calling read_text() and raise/return a clear
error (or None) for oversized files; reference the functions
list_local_resources and read_skill_resource and perform the size check in both
places using the same MAX_RESOURCE_SIZE_BYTES constant.

175-220: ⚠️ Potential issue | 🟠 Major

Escape metadata before embedding it in the XML-style prompt blocks.

info.name, info.description, and resource names are interpolated raw into tags and attributes here. A <, &, or quote from SKILL.md or the filesystem can break the wrapper and alter the prompt structure.

Possible low-impact fix
+from html import escape
+
 ...
     skill_entries = "\n".join(
         f"    <skill>\n"
-        f"        <name>{info.name}</name>\n"
-        f"        <description>{info.description}</description>\n"
+        f"        <name>{escape(info.name)}</name>\n"
+        f"        <description>{escape(info.description)}</description>\n"
         f"    </skill>"
         for info in skills.values()
     )
 ...
     if info.resources:
-        resource_entries = "\n".join(f"<file>{res}</file>" for res in info.resources)
+        resource_entries = "\n".join(
+            f"<file>{escape(res)}</file>" for res in info.resources
+        )
         resources_section = (
             f"\n<skill_resources>\n{resource_entries}\n</skill_resources>\n"
             "Use the read_skill_resource tool to read any of these files if needed.\n"
         )
 
     return (
-        f'<skill_content name="{info.name}">\n'
+        f'<skill_content name="{escape(info.name, quote=True)}">\n'
         f"{info.body}\n"
         f"{resources_section}"
         "</skill_content>\n"
     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@python-interpreter/packages/afm-core/src/afm/skills.py` around lines 175 -
220, Escape XML/HTML-special characters before embedding skill metadata and
resources into the XML-style blocks: update build_skill_catalog (the
skill_entries construction that interpolates info.name and info.description) and
activate_skill (the f-string that builds '<skill_content name="{info.name}">'
and the resources_section/resource_entries that include resource names and
info.body) to run values through an XML-escaping utility (e.g., html.escape or a
small helper that replaces &, <, >, " and ') so names, descriptions, body text
and filenames cannot break the wrapper or inject tags/attributes.

89-93: ⚠️ Potential issue | 🟡 Minor

Don't let one malformed direct skill source abort discovery.

Line 92 calls parse_skill_md() outside the ValueError guard used for subdirectory skills. If a configured source points directly at a skill folder, one bad SKILL.md will abort discovery instead of being logged and skipped like the subdirectory case.

Possible low-impact fix
     skill_md_path = path / SKILL_FILE
     if skill_md_path.is_file():
-        info = parse_skill_md(skill_md_path, path)
-        return {info.name: info}
+        try:
+            info = parse_skill_md(skill_md_path, path)
+        except ValueError as e:
+            logger.error("Failed to parse skill at %s: %s", path, e)
+            return {}
+        return {info.name: info}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@python-interpreter/packages/afm-core/src/afm/skills.py` around lines 89 - 93,
The direct-skill branch calls parse_skill_md(path / SKILL_FILE) without the
ValueError guard used for subdirectory skills, so a malformed SKILL.md aborts
discovery; wrap the call to parse_skill_md in a try/except ValueError (matching
the handling used for subdirectory discovery), log the error with the same
logger/format used elsewhere, and on exception skip the skill by returning an
empty dict (or otherwise continuing) instead of letting the error propagate;
target the code around skill_md_path, SKILL_FILE, parse_skill_md and mirror the
existing subdirectory error handling.
python-interpreter/packages/afm-langchain/src/afm_langchain/tools/skills.py (1)

40-76: ⚠️ Potential issue | 🟠 Major

Enforce activation before serving skill resources.

ActivateSkillTool still never records which skills were activated, and ReadSkillResourceTool can read any discovered resource as long as the caller knows skill_name and resource_path. In python-interpreter/packages/afm-langchain/src/afm_langchain/backend.py Lines 70-76, both tools are still constructed with only skills=skills, so the model can bypass the progressive-disclosure contract by calling read_skill_resource first. Share a per-run activation state between these tools and reject reads until the matching skill has been activated.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@python-interpreter/packages/afm-langchain/src/afm_langchain/tools/skills.py`
around lines 40 - 76, ActivateSkillTool currently doesn't record activations and
ReadSkillResourceTool can be called without activation; add a shared per-run
activation state (e.g., a set[str] named activated_skills) and pass it into both
tool instances instead of only skills; modify ActivateSkillTool to add the
activated skill to activated_skills after a successful call to
activate_skill(name, self.skills) (use the ActivateSkillTool class and its _run
method), and modify ReadSkillResourceTool._run to first check that skill_name is
in activated_skills and raise/return an error if not before calling
read_skill_resource(skill_name, resource_path, self.skills); update backend
construction where the tools are instantiated so both get the same
activated_skills object.
🧹 Nitpick comments (1)
ballerina-interpreter/parser.bal (1)

279-285: Improve skills.path error precision with index-based keys.

Current reporting always pushes skills.path, which can be ambiguous when multiple skills are invalid.

Proposed diff
-    if skills is SkillSource[] {
-        foreach SkillSource skillSource in skills {
-            if containsHttpVariable(skillSource.path) {
-                erroredKeys.push("skills.path");
-            }
-        }
-    }
+    if skills is SkillSource[] {
+        foreach int idx in 0 ..< skills.length() {
+            SkillSource skillSource = skills[idx];
+            if containsHttpVariable(skillSource.path) {
+                erroredKeys.push(string `skills[${idx}].path`);
+            }
+        }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ballerina-interpreter/parser.bal` around lines 279 - 285, The current loop
always pushes the generic key "skills.path", causing ambiguity when multiple
SkillSource entries are invalid; change the iteration to capture the skill index
(e.g., use an indexed for/foreach or iterate over 0..skills.length()-1) and when
containsHttpVariable(skillSource.path) is true push an index-specific key such
as "skills[<index>].path" (or `skills.<index>.path` if your project convention
prefers) instead of the constant "skills.path" so each invalid skill is reported
with its exact array index; update the block that uses skills, SkillSource,
containsHttpVariable, and erroredKeys.push accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@python-interpreter/packages/afm-core/src/afm/skills.py`:
- Around line 162-172: list_local_resources currently advertises every file even
if too large for read_skill_resource; add a MAX_RESOURCE_SIZE_BYTES constant and
use entry.stat().st_size to skip adding any file whose size exceeds that
threshold so the catalog matches what can be served, and update
read_skill_resource to check Path.stat().st_size before calling read_text() and
raise/return a clear error (or None) for oversized files; reference the
functions list_local_resources and read_skill_resource and perform the size
check in both places using the same MAX_RESOURCE_SIZE_BYTES constant.
- Around line 175-220: Escape XML/HTML-special characters before embedding skill
metadata and resources into the XML-style blocks: update build_skill_catalog
(the skill_entries construction that interpolates info.name and
info.description) and activate_skill (the f-string that builds '<skill_content
name="{info.name}">' and the resources_section/resource_entries that include
resource names and info.body) to run values through an XML-escaping utility
(e.g., html.escape or a small helper that replaces &, <, >, " and ') so names,
descriptions, body text and filenames cannot break the wrapper or inject
tags/attributes.
- Around line 89-93: The direct-skill branch calls parse_skill_md(path /
SKILL_FILE) without the ValueError guard used for subdirectory skills, so a
malformed SKILL.md aborts discovery; wrap the call to parse_skill_md in a
try/except ValueError (matching the handling used for subdirectory discovery),
log the error with the same logger/format used elsewhere, and on exception skip
the skill by returning an empty dict (or otherwise continuing) instead of
letting the error propagate; target the code around skill_md_path, SKILL_FILE,
parse_skill_md and mirror the existing subdirectory error handling.

In `@python-interpreter/packages/afm-langchain/src/afm_langchain/tools/skills.py`:
- Around line 40-76: ActivateSkillTool currently doesn't record activations and
ReadSkillResourceTool can be called without activation; add a shared per-run
activation state (e.g., a set[str] named activated_skills) and pass it into both
tool instances instead of only skills; modify ActivateSkillTool to add the
activated skill to activated_skills after a successful call to
activate_skill(name, self.skills) (use the ActivateSkillTool class and its _run
method), and modify ReadSkillResourceTool._run to first check that skill_name is
in activated_skills and raise/return an error if not before calling
read_skill_resource(skill_name, resource_path, self.skills); update backend
construction where the tools are instantiated so both get the same
activated_skills object.

---

Nitpick comments:
In `@ballerina-interpreter/parser.bal`:
- Around line 279-285: The current loop always pushes the generic key
"skills.path", causing ambiguity when multiple SkillSource entries are invalid;
change the iteration to capture the skill index (e.g., use an indexed
for/foreach or iterate over 0..skills.length()-1) and when
containsHttpVariable(skillSource.path) is true push an index-specific key such
as "skills[<index>].path" (or `skills.<index>.path` if your project convention
prefers) instead of the constant "skills.path" so each invalid skill is reported
with its exact array index; update the block that uses skills, SkillSource,
containsHttpVariable, and erroredKeys.push accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 58fe104d-a774-48b3-b534-086129599898

📥 Commits

Reviewing files that changed from the base of the PR and between eaa5ba0 and fed87ef.

📒 Files selected for processing (5)
  • ballerina-interpreter/parser.bal
  • ballerina-interpreter/skills.bal
  • python-interpreter/packages/afm-core/src/afm/models.py
  • python-interpreter/packages/afm-core/src/afm/skills.py
  • python-interpreter/packages/afm-langchain/src/afm_langchain/tools/skills.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • ballerina-interpreter/skills.bal

@MaryamZi MaryamZi merged commit cec1ace into main Mar 23, 2026
6 checks passed
@MaryamZi MaryamZi deleted the agent-skills branch March 23, 2026 05:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants