Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# btw (development version)

* Skill listings in the system prompt now use compact YAML-style entries instead of `<skill>` XML blocks, reducing prompt tokens while conveying the same information.

* Package tests now support a compact reporter that shows per-file progress, timing, warning and failure details, and final counts. `btw pkg test` uses it by default, while `btw_tool_pkg_test()` defaults to concise output for non-streaming clients; both accept other testthat reporter names (#220).

# btw 1.5.0
Expand Down
4 changes: 2 additions & 2 deletions R/mcp.R
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,10 @@ btw_mcp_session <- function() {

btw_mcp_tools <- function() {
# Skills are excluded from MCP by default: the skill system prompt
# (with <available_skills> metadata) is injected by btw_client(), not by
# (with the available-skills listing) is injected by btw_client(), not by
# MCP. Without that context the model has no way to know which skills are
# available. Filesystem-based agents (e.g. Claude Code) can read SKILL.md
# files directly via their <location> paths in the system prompt.
# files directly via their listed locations in the system prompt.
all_tools <- btw_tools()
Filter(
function(tool) !identical(tool@annotations$btw_group, "skills"),
Expand Down
60 changes: 24 additions & 36 deletions R/tool-skills.R
Original file line number Diff line number Diff line change
Expand Up @@ -151,18 +151,18 @@ btw_skill_resolve <- function(skill_name) {
#' Render a skill's entry for a system prompt
#'
#' @description
#' Returns the `<skill>` block for one skill: its name, description, and
#' location, plus its compatibility notes and allowed tools when present. This
#' is the same block that [btw_client()] writes into its system prompt.
#' Returns a compact YAML-style entry for one skill: its name, description,
#' and location, plus compatibility notes and allowed tools when present. This
#' is the same entry that [btw_client()] writes into its system prompt.
#'
#' Compose the listing yourself. For example, wrap the blocks for all skills
#' in an `<available_skills>` element, the way btw does it.
#' Compose the listing yourself by joining the entries under an
#' "Available skills:" heading, the way btw does it.
#'
#' If the skill doesn't exist, the error lists the available skill names.
#'
#' @param skill_name The name of the skill, e.g. `"skill-creator"`.
#'
#' @return A single string with the skill's `<skill>` block.
#' @return A single string with the skill's YAML-style entry.
#'
#' @examples
#' cat(btw_skill_prompt("skill-creator"))
Expand Down Expand Up @@ -749,16 +749,6 @@ format_resources_listing <- function(resources, base_dir) {
paste(parts, collapse = "")
}

# Escapes &, <, > for use in XML text content. This XML is decorative
# formatting for LLM system prompts, not parsed by an XML parser, so we
# intentionally omit attribute-level escapes (" and ').
xml_escape <- function(x) {
x <- gsub("&", "&amp;", x, fixed = TRUE)
x <- gsub("<", "&lt;", x, fixed = TRUE)
x <- gsub(">", "&gt;", x, fixed = TRUE)
x
}

# R Package Build Ignore ---------------------------------------------------

maybe_use_build_ignore <- function(target_parent, project_dir = getwd()) {
Expand Down Expand Up @@ -835,37 +825,36 @@ escape_for_rbuildignore <- function(path) {

# System Prompt ------------------------------------------------------------

# Renders one skill as a <skill> block: name, description, location, plus
# compatibility notes and allowed tools when present. Callers compose the
# blocks into their own system prompt; btw_skills_system_prompt() wraps them
# in <available_skills>, btw_skill_prompt() returns a single block.
# Keep frontmatter block scalars on one line so each entry stays together.
skill_prompt_line <- function(x) {
gsub("[\r\n]+[ \t]*", " ", x)
}

# Renders one skill as a compact YAML-style entry. Both the system prompt and
# btw_skill_prompt() use the same entry format.
format_skill_prompt <- function(skill) {
parts <- sprintf(
"<skill>\n<name>%s</name>\n<description>%s</description>\n<location>%s</location>",
xml_escape(skill$name),
xml_escape(skill$description),
xml_escape(skill$path)
"- %s: %s\n location: %s",
skill_prompt_line(skill$name),
skill_prompt_line(skill$description),
skill_prompt_line(skill$path)
)
if (!is.null(skill$compatibility)) {
parts <- paste0(
parts,
sprintf(
"\n<compatibility>%s</compatibility>",
xml_escape(skill$compatibility)
)
"\n compatibility: ",
skill_prompt_line(skill$compatibility)
)
}
if (!is.null(skill$allowed_tools)) {
allowed_tools <- paste(skill$allowed_tools, collapse = ", ")
parts <- paste0(
parts,
sprintf(
"\n<allowed-tools>%s</allowed-tools>",
xml_escape(allowed_tools)
)
"\n allowed-tools: ",
skill_prompt_line(allowed_tools)
)
}
paste0(parts, "\n</skill>")
parts
}

btw_skills_system_prompt <- function() {
Expand All @@ -886,9 +875,8 @@ btw_skills_system_prompt <- function() {

paste0(
explanation,
"\n\n<available_skills>\n",
paste(skill_items, collapse = "\n"),
"\n</available_skills>"
"\n\nAvailable skills:\n",
paste(skill_items, collapse = "\n")
)
}

Expand Down
14 changes: 1 addition & 13 deletions inst/prompts/skills.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,3 @@
## Skills

You have access to specialized skills that provide detailed guidance for specific tasks. Skills are loaded on-demand to provide domain-specific expertise without consuming context until needed.

### Using Skills

1. **Check available skills**: Review the `<available_skills>` listing below
2. **Load when relevant**: When you recognize that a task matches a skill's description, call `btw_tool_skill(name)` to load the full skill instructions
3. **Don't reload**: If a skill has already been loaded in this conversation, follow its instructions directly without loading it again
4. **Access resources**: After loading, use file read tools to access references

Skills may include bundled resources:
- **Scripts**: Code bundled with the skill. Scripts are not directly executable by btw; read them for reference or adapt their logic into R code for use with the R code execution tool.
- **References**: Additional documentation to consult as needed
- **Assets**: Templates and files for use in outputs
When a task matches an available skill, call `btw_tool_skill(name)` to load its instructions. Don't reload skills already loaded. Resolve relative paths in a skill against the directory containing its SKILL.md. Use file read tools for bundled references; bundled scripts are for reference or adaptation into R, not directly executable by btw.
12 changes: 6 additions & 6 deletions man/btw_skill_prompt.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 4 additions & 20 deletions tests/testthat/_snaps/tool_skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,11 @@
Output
## Skills

You have access to specialized skills that provide detailed guidance for specific tasks. Skills are loaded on-demand to provide domain-specific expertise without consuming context until needed.
When a task matches an available skill, call `btw_tool_skill(name)` to load its instructions. Don't reload skills already loaded. Resolve relative paths in a skill against the directory containing its SKILL.md. Use file read tools for bundled references; bundled scripts are for reference or adaptation into R, not directly executable by btw.

### Using Skills

1. **Check available skills**: Review the `<available_skills>` listing below
2. **Load when relevant**: When you recognize that a task matches a skill's description, call `btw_tool_skill(name)` to load the full skill instructions
3. **Don't reload**: If a skill has already been loaded in this conversation, follow its instructions directly without loading it again
4. **Access resources**: After loading, use file read tools to access references

Skills may include bundled resources:
- **Scripts**: Code bundled with the skill. Scripts are not directly executable by btw; read them for reference or adapt their logic into R code for use with the R code execution tool.
- **References**: Additional documentation to consult as needed
- **Assets**: Templates and files for use in outputs

<available_skills>
<skill>
<name>skill-creator</name>
<description>Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.</description>
<location>SKILL_PATH</location>
</skill>
</available_skills>
Available skills:
- skill-creator: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.
location: SKILL_PATH

# btw_skill_prompt() errors for unknown or invalid skills

Expand Down
59 changes: 31 additions & 28 deletions tests/testthat/test-tool_skills.R
Original file line number Diff line number Diff line change
Expand Up @@ -807,9 +807,9 @@ test_that("btw_skills_system_prompt() includes skill metadata", {
local_skill_dirs(dir)

prompt <- btw_skills_system_prompt()
expect_match(prompt, "<name>prompt-test</name>")
expect_match(prompt, "A skill for testing prompts.")
expect_match(prompt, "<compatibility>Needs R 4.2</compatibility>")
expect_match(prompt, "- prompt-test: A skill for testing prompts.", fixed = TRUE)
expect_match(prompt, " location: ", fixed = TRUE)
expect_match(prompt, " compatibility: Needs R 4.2", fixed = TRUE)
})

# select_skill_dir ----------------------------------------------------------
Expand Down Expand Up @@ -1291,7 +1291,7 @@ test_that("btw_skills_system_prompt() works", {
expect_snapshot(
cat(btw_skills_system_prompt()),
transform = function(x) {
gsub("<location>.*?</location>", "<location>SKILL_PATH</location>", x)
gsub(" location: .*SKILL\\.md", " location: SKILL_PATH", x)
}
)
})
Expand Down Expand Up @@ -1384,9 +1384,9 @@ test_that("install_skill_from_dir() overwrites with overwrite = TRUE", {
expect_true(any(grepl("Version 2", content)))
})

# xml_escape() in system prompt ---------------------------------------------
# Description formatting in skill listing ----------------------------------

test_that("btw_skills_system_prompt() escapes XML special characters", {
test_that("btw_skills_system_prompt() preserves special characters", {
dir <- withr::local_tempdir()
create_temp_skill(
name = "esc-test",
Expand All @@ -1396,9 +1396,21 @@ test_that("btw_skills_system_prompt() escapes XML special characters", {
local_skill_dirs(dir)

prompt <- btw_skills_system_prompt()
expect_match(prompt, "&lt;tags&gt;", fixed = TRUE)
expect_match(prompt, "&amp; ampersands", fixed = TRUE)
expect_no_match(prompt, "<tags>", fixed = TRUE)
expect_match(prompt, "Uses <tags> & ampersands.", fixed = TRUE)
})

test_that("btw_skills_system_prompt() keeps multiline descriptions in one entry", {
dir <- withr::local_tempdir()
create_temp_skill(
name = "multiline-test",
description = "First line.\nSecond line.",
dir = dir
)
local_skill_dirs(dir)

prompt <- btw_skills_system_prompt()
expect_match(prompt, "- multiline-test: First line. Second line.", fixed = TRUE)
expect_match(prompt, " location: .*multiline-test.*SKILL\\.md")
})

# maybe_use_build_ignore() -------------------------------------------------
Expand Down Expand Up @@ -1570,9 +1582,9 @@ test_that("skills prompt is included in btw_client() system prompt", {
system_prompt <- chat$get_system_prompt()

expect_match(system_prompt, "## Skills", fixed = TRUE)
expect_match(system_prompt, "<name>skill-creator</name>", fixed = TRUE)
expect_match(system_prompt, "- skill-creator:", fixed = TRUE)
})
test_that("btw_skill_prompt() renders the <skill> block for one skill", {
test_that("btw_skill_prompt() renders a YAML-style entry for one skill", {
dir <- withr::local_tempdir()
create_temp_skill(
"demo-skill",
Expand All @@ -1589,22 +1601,13 @@ test_that("btw_skill_prompt() renders the <skill> block for one skill", {
expect_type(text, "character")
expect_length(text, 1)

# the block carries the same fields as the btw_client() system prompt
expect_match(text, "<name>demo-skill</name>", fixed = TRUE)
expect_match(
text,
"<description>A test skill for unit testing.</description>",
fixed = TRUE
)
expect_match(text, "<location>.*SKILL\\.md</location>")
expect_match(
text,
"<compatibility>Requires Python 3</compatibility>",
fixed = TRUE
)
expect_match(text, "<allowed-tools>Read Bash</allowed-tools>", fixed = TRUE)
# the entry carries the same fields as the btw_client() system prompt
expect_match(text, "- demo-skill: A test skill for unit testing.", fixed = TRUE)
expect_match(text, " location: .*SKILL\\.md")
expect_match(text, " compatibility: Requires Python 3", fixed = TRUE)
expect_match(text, " allowed-tools: Read Bash", fixed = TRUE)

# the skill body is not part of the block
# the skill body is not part of the entry
expect_false(grepl("Do the thing.", text, fixed = TRUE))
})

Expand All @@ -1614,8 +1617,8 @@ test_that("btw_skill_prompt() omits optional fields when absent", {
local_skill_dirs(dir)

text <- btw_skill_prompt("demo-skill")
expect_false(grepl("<compatibility>", text, fixed = TRUE))
expect_false(grepl("<allowed-tools>", text, fixed = TRUE))
expect_false(grepl(" compatibility:", text, fixed = TRUE))
expect_false(grepl(" allowed-tools:", text, fixed = TRUE))
})

test_that("btw_skill_prompt() errors for unknown or invalid skills", {
Expand Down
Loading