Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
c246018
feat: add AgentSkills standard fields to Skill model
openhands-agent Dec 22, 2025
c033966
chore: remove unused agentskills optional dependency
openhands-agent Dec 22, 2025
c7b67b6
feat: support SKILL.md file convention and name validation
openhands-agent Dec 22, 2025
2fb7735
feat: support .mcp.json for MCP server configuration
openhands-agent Dec 22, 2025
f24f016
feat: support resource directories (scripts/, references/, assets/)
openhands-agent Dec 22, 2025
e8430f0
feat: add validation and prompt generation utilities
openhands-agent Dec 22, 2025
3427e4c
refactor: consolidate AgentSkills tests
openhands-agent Dec 22, 2025
fed0593
refactor: consolidate AgentSkills tests
openhands-agent Dec 22, 2025
b5e571f
refactor: consolidate AgentSkills tests
openhands-agent Dec 22, 2025
bd523d0
Merge main into feat/skill-md-convention
openhands-agent Dec 22, 2025
1d5f15a
refactor: modularize load_skills_from_dir function
openhands-agent Dec 22, 2025
c661e2f
Resolve merge conflicts with main branch
openhands-agent Dec 22, 2025
b33afe8
Merge main into feat/skill-md-convention
openhands-agent Dec 28, 2025
1d2c579
refactor: address code review feedback for SKILL.md convention
openhands-agent Dec 28, 2025
5649c81
Merge branch 'main' into feat/skill-md-convention
neubig Dec 29, 2025
f1dc623
fix: AgentSkills (SKILL.md) always use progressive loading
openhands-agent Dec 29, 2025
feb7a2d
Update organization
neubig Dec 30, 2025
e86afb8
Remove file_content parameter from Skill.load()
neubig Dec 30, 2025
a53f3a0
Refactor Skill.load() into separate code paths for AgentSkills and le…
neubig Dec 30, 2025
821f5b5
Merge remote-tracking branch 'origin/main' into feat/skill-md-convention
openhands-agent Dec 30, 2025
4f85894
feat: Add agent_skills as third category for AgentSkills standard
openhands-agent Dec 30, 2025
e22f6c5
feat: support .mcp.json for MCP server configuration (agent_skills only)
openhands-agent Dec 30, 2025
5a541f3
Merge branch 'main' into feat/resource-directories
openhands-agent Dec 31, 2025
8565075
Resolve merge conflicts with main branch
openhands-agent Dec 31, 2025
bfc17a7
Merge main into feat/mcp-json-support, preserving .mcp.json support
openhands-agent Dec 31, 2025
bba56fa
fix: remove duplicate path.parent.name assignment
openhands-agent Dec 31, 2025
99759d7
Merge origin/feat/mcp-json-support into feat/resource-directories
openhands-agent Dec 31, 2025
c633cd7
Merge branch 'main' into feat/validation-prompt-utils
neubig Jan 2, 2026
e34051e
Merge branch 'main' into feat/resource-directories
neubig Jan 2, 2026
e0963ec
Merge branch 'main' into feat/mcp-json-support
neubig Jan 2, 2026
3f77eee
Merge branch 'main' into feat/mcp-json-support
neubig Jan 5, 2026
b13da52
Merge latest from feat/mcp-json-support into feat/resource-directories
openhands-agent Jan 5, 2026
de37286
Merge remote changes from feat/resource-directories
openhands-agent Jan 5, 2026
0c5179b
refactor: move utility functions to utils.py
openhands-agent Jan 5, 2026
b9f83af
Merge feat/mcp-json-support into feat/resource-directories
openhands-agent Jan 5, 2026
e2be31c
refactor: remove non-resource-directory changes from PR
openhands-agent Jan 5, 2026
85d0ea3
refactor: move discover_skill_resources and RESOURCE_DIRECTORIES to u…
openhands-agent Jan 5, 2026
1fdc57b
Merge origin/main into feat/resource-directories
openhands-agent Jan 5, 2026
317b258
Merge origin/feat/resource-directories into feat/validation-prompt-utils
openhands-agent Jan 5, 2026
d6deeff
refactor: remove validate_skill() in favor of Pydantic validation
openhands-agent Jan 5, 2026
68667e9
refactor: keep only to_prompt() in this PR
openhands-agent Jan 5, 2026
2b4103d
Merge origin/main into feat/validation-prompt-utils
openhands-agent Jan 6, 2026
d18349f
refactor: improve to_prompt() based on review feedback
openhands-agent Jan 6, 2026
3fd1b53
refactor: use xml.sax.saxutils.escape and count remaining content as …
openhands-agent Jan 6, 2026
3a223ac
refactor: inline _escape_xml function
openhands-agent Jan 6, 2026
919093a
Merge branch 'main' into feat/validation-prompt-utils
neubig Jan 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openhands-sdk/openhands/sdk/context/skills/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
load_public_skills,
load_skills_from_dir,
load_user_skills,
to_prompt,
)
from openhands.sdk.context.skills.trigger import (
BaseTrigger,
Expand Down Expand Up @@ -33,4 +34,5 @@
"SkillValidationError",
"discover_skill_resources",
"RESOURCE_DIRECTORIES",
"to_prompt",
]
70 changes: 70 additions & 0 deletions openhands-sdk/openhands/sdk/context/skills/skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import re
from pathlib import Path
from typing import Annotated, ClassVar, Union
from xml.sax.saxutils import escape as xml_escape

import frontmatter
from fastmcp.mcp_config import MCPConfig
Expand Down Expand Up @@ -776,3 +777,72 @@ def load_public_skills(
f"Loaded {len(all_skills)} public skills: {[s.name for s in all_skills]}"
)
return all_skills


def to_prompt(skills: list[Skill], max_description_length: int = 200) -> str:
"""Generate XML prompt block for available skills.

Creates an `<available_skills>` XML block suitable for inclusion
in system prompts, following the AgentSkills format.

Args:
skills: List of skills to include in the prompt
max_description_length: Maximum length for descriptions (default 200)

Returns:
XML string in AgentSkills format

Example:
>>> skills = [Skill(name="pdf-tools", content="...", description="...")]
>>> print(to_prompt(skills))
<available_skills>
<skill name="pdf-tools">Extract text from PDF files.</skill>
</available_skills>
""" # noqa: E501
if not skills:
return "<available_skills>\n no available skills\n</available_skills>"

lines = ["<available_skills>"]
for skill in skills:
# Use description if available, otherwise use first line of content
description = skill.description
content_truncated = 0
if not description:
# Extract first non-empty, non-header line from content as fallback
# Track position to calculate truncated content after the description
chars_before_desc = 0
for line in skill.content.split("\n"):
stripped = line.strip()
# Skip markdown headers and empty lines
if not stripped or stripped.startswith("#"):
chars_before_desc += len(line) + 1 # +1 for newline
continue
description = stripped
# Calculate remaining content after this line as truncated
desc_end_pos = chars_before_desc + len(line)
content_truncated = max(0, len(skill.content) - desc_end_pos)
break
Comment on lines +811 to +824
Copy link
Collaborator

Choose a reason for hiding this comment

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

@OpenHands /codereview /codereview-roasted

Copy link

Choose a reason for hiding this comment

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

I'm on it! xingyaoww can track my progress at all-hands.dev

Copy link

Choose a reason for hiding this comment

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

Summary

I provided a Linus Torvalds-style code review for PR #1483 (feat: add to_prompt() for XML skill prompt generation) as requested with the /codereview-roasted command.

Review Verdict: 🟡 Acceptable - Worth merging

Key Findings:

  1. Core logic is sound - The XML generation works correctly and uses the standard library (xml.sax.saxutils.escape) for escaping, which is good.

  2. Main improvement opportunity (Lines 809-824): The manual character position tracking for content fallback is fragile and over-engineered. The code tracks chars_before_desc, desc_end_pos, and content_truncated through manual byte counting - this is error-prone and could be simplified to just detect "has more content" rather than counting exact characters.

  3. Over-engineering concern (Lines 827-840): The exact truncation character counts (e.g., "47 characters truncated") solve an imaginary problem. Users don't care about exact counts - they just need to know there's more content and where to find it.

Suggested simplification: Replace the character-counting logic with a simpler "are there more meaningful lines?" check, and simplify the truncation message to something like "... [see /skills/test.md for full content]".

No code changes were made - this was a review-only request. The feedback has been provided above for the PR author to consider.

View full conversation

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@xingyaoww Would you like to make this change? Or keep as is? I kinda feel as-is might be OK

description = description or ""

# Calculate total truncated characters
total_truncated = content_truncated

# Truncate description if needed and add truncation indicator
if len(description) > max_description_length:
total_truncated += len(description) - max_description_length
description = description[:max_description_length]

if total_truncated > 0:
truncation_msg = f"... [{total_truncated} characters truncated"
if skill.source:
truncation_msg += f". View {skill.source} for complete information"
truncation_msg += "]"
description = description + truncation_msg

# Escape XML special characters using standard library
xml_entities = {'"': "&quot;", "'": "&apos;"}
description = xml_escape(description, entities=xml_entities)
name = xml_escape(skill.name, entities=xml_entities)
lines.append(f' <skill name="{name}">{description}</skill>')
lines.append("</available_skills>")
return "\n".join(lines)
90 changes: 90 additions & 0 deletions tests/sdk/context/skill/test_validation_prompt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Tests for prompt generation utilities (Issue #1478)."""

from openhands.sdk.context.skills import (
Skill,
to_prompt,
)


def test_to_prompt_generates_xml() -> None:
"""to_prompt() should generate valid XML for skills."""
# Empty list shows "no available skills"
assert (
to_prompt([])
== "<available_skills>\n no available skills\n</available_skills>"
)

# Single skill with description
skill = Skill(name="pdf-tools", content="# PDF", description="Process PDFs.")
result = to_prompt([skill])
assert '<skill name="pdf-tools">' in result
assert "Process PDFs." in result
assert "<available_skills>" in result

# Multiple skills
skills = [
Skill(name="pdf-tools", content="# PDF", description="Process PDFs."),
Skill(name="code-review", content="# Code", description="Review code."),
]
result = to_prompt(skills)
assert result.count("<skill") == 2


def test_to_prompt_escapes_xml() -> None:
"""to_prompt() should escape XML special characters."""
skill = Skill(
name="test", content="# Test", description='Handle <tags> & "quotes".'
)
result = to_prompt([skill])
assert "&lt;tags&gt;" in result
assert "&amp;" in result
assert "&quot;quotes&quot;" in result


def test_to_prompt_uses_content_fallback() -> None:
"""to_prompt() should use content when no description."""
skill = Skill(name="test", content="# Header\n\nActual content here.")
result = to_prompt([skill])
assert "Actual content here." in result
assert "# Header" not in result


def test_to_prompt_content_fallback_counts_remaining_as_truncated() -> None:
"""to_prompt() should count content after first line as truncated."""
# Content with header, description line, and additional content
content = "# Header\n\nFirst line used as description.\n\nMore content here."
skill = Skill(name="test", content=content, source="/skills/test.md")
result = to_prompt([skill])

# Should use first non-header line as description
assert "First line used as description." in result
# Should indicate truncation for remaining content
assert "characters truncated" in result
assert "View /skills/test.md for complete information" in result


def test_to_prompt_truncates_long_descriptions() -> None:
"""to_prompt() should truncate long descriptions with indicator."""
long_desc = "A" * 250 # 250 characters
skill = Skill(name="test", content="# Test", description=long_desc)
result = to_prompt([skill])

# Should contain truncation indicator
assert "... [50 characters truncated]" in result
# Should contain first 200 chars
assert "A" * 200 in result


def test_to_prompt_truncation_includes_source() -> None:
"""to_prompt() should include source path in truncation message."""
long_desc = "B" * 250
skill = Skill(
name="test",
content="# Test",
description=long_desc,
source="/path/to/skill.md",
)
result = to_prompt([skill])

assert "... [50 characters truncated" in result
assert "View /path/to/skill.md for complete information]" in result
Loading