diff --git a/.gitignore b/.gitignore index 372e6a0f..18965bfb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ .quarto docs .claude/settings.local.json +_dev diff --git a/DESCRIPTION b/DESCRIPTION index b8b29507..e9d3ff5f 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -88,6 +88,7 @@ Collate: 'btw.R' 'btw_client.R' 'btw_client_app.R' + 'btw_task.R' 'btw_this.R' 'clipboard.R' 'deprecated.R' diff --git a/NAMESPACE b/NAMESPACE index 746fa91c..948d1884 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -26,6 +26,7 @@ export(btw_app) export(btw_client) export(btw_mcp_server) export(btw_mcp_session) +export(btw_task) export(btw_task_create_btw_md) export(btw_task_create_readme) export(btw_this) diff --git a/NEWS.md b/NEWS.md index fff0de9e..7e03896c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,13 @@ # btw (development version) +* New `btw_task()` function runs pre-formatted LLM tasks defined in markdown + files with YAML frontmatter. Task files support template variable + interpolation via `{{ variable }}` syntax, optional client and tool + configuration, and all four execution modes (`"app"`, `"console"`, + `"client"`, `"tool"`). Task files can also specify `title`, `icon`, + `description`, and `name` fields in their YAML frontmatter to customize + the tool definition when used in `"tool"` mode (#42). + * New `btw_tool_files_edit()` tool makes targeted, validated line-based edits to files using `replace`, `insert_after`, and `replace_range` actions. Edits are anchored to content hashes, so stale edits are rejected if the file has diff --git a/R/btw_task.R b/R/btw_task.R new file mode 100644 index 00000000..aee7db7e --- /dev/null +++ b/R/btw_task.R @@ -0,0 +1,279 @@ +#' Run a pre-formatted btw task +#' +#' @description +#' Runs a btw task defined in a file with YAML frontmatter configuration and +#' a markdown body containing the task prompt. The task file format is similar +#' to `btw.md` files, with client and tool configuration in the frontmatter and +#' the task instructions in the body. +#' +#' ## Task File Format +#' +#' Task files use the same format as `btw.md` files: +#' +#' ```yaml +#' --- +#' client: +#' provider: anthropic +#' model: claude-sonnet-4 +#' tools: [docs, files] +#' --- +#' +#' Your task prompt here with {{ variable }} interpolation... +#' ``` +#' +#' ## Template Variables +#' +#' The task prompt body supports template variable interpolation using +#' `{{ variable }}` syntax via [ellmer::interpolate()]. Pass named arguments +#' to provide values for template variables: +#' +#' ```r +#' btw_task("my-task.md", package_name = "dplyr", version = "1.1.0") +#' ``` +#' +#' ## Additional Context +#' +#' Unnamed arguments are treated as additional context and converted to text +#' using [btw()]. This context is appended to the system prompt: +#' +#' ```r +#' btw_task("analyze.md", dataset_name = "mtcars", mtcars, my_function) +#' # ^-- template var ^-- additional context +#' ``` +#' +#' @param path Path to the task file containing YAML configuration and prompt. +#' @param ... Named arguments become template variables for interpolation in the +#' task prompt. Unnamed arguments are treated as additional context objects +#' and converted to text via [btw()]. +#' @param client An [ellmer::Chat] client to override the task file's client +#' configuration. If `NULL`, uses the client specified in the task file's +#' YAML frontmatter, falling back to the default client resolution of +#' [btw_client()]. +#' @param mode The execution mode for the task: +#' - `"app"`: Launch interactive Shiny app (default) +#' - `"console"`: Interactive console chat with [ellmer::live_console()] +#' - `"client"`: Return configured [ellmer::Chat] client without running +#' - `"tool"`: Return an [ellmer::tool()] object for programmatic use +#' +#' @return Depending on `mode`: +#' - `"app"`: Returns the chat client invisibly after launching the app +#' - `"console"`: Returns the chat client after console interaction +#' - `"client"`: Returns the configured chat client +#' - `"tool"`: Returns an [ellmer::tool()] object +#' +#' @examples +#' # Create a simple task file +#' tmp_task_file <- tempfile(fileext = ".md") +#' +#' cat(file = tmp_task_file, '--- +#' client: anthropic/claude-sonnet-4-6 +#' tools: [docs, files] +#' --- +#' +#' Analyze the {{ package_name }} package and create a summary. +#' ') +#' +#' # Task with template interpolation +#' btw_task(tmp_task_file, package_name = "dplyr", mode = "tool") +#' +#' # Include additional context +#' btw_task( +#' tmp_task_file, +#' package_name = "ggplot2", +#' mtcars, # Additional context +#' mode = "tool" +#' ) +#' +#' @family task and agent functions +#' @export +btw_task <- function( + path, + ..., + client = NULL, + mode = c("app", "console", "client", "tool") +) { + check_string(path) + mode <- arg_match(mode) + + if (!fs::file_exists(path)) { + cli::cli_abort("Task file not found: {.path {path}}") + } + + task_config <- read_single_btw_file(path) + task_prompt <- task_config$btw_system_prompt + task_config$btw_system_prompt <- NULL + + if (is.null(task_prompt) || !nzchar(task_prompt)) { + cli::cli_abort( + "Task file must contain a prompt in the body: {.path {path}}" + ) + } + + # Capture dots as quosures to preserve expressions (needed for btw() naming) + all_quos <- enquos(...) + quo_names <- names2(all_quos) + + # Named quos → template variables (evaluated); unnamed quos → context objects + template_vars <- lapply(all_quos[nzchar(quo_names)], eval_tidy) + context_quos <- all_quos[!nzchar(quo_names)] + + # Interpolate template variables in the task prompt + if (length(template_vars) > 0) { + task_prompt <- ellmer::interpolate(task_prompt, !!!template_vars) + } + + # Build final system prompt: task body + optional additional context + final_system_prompt <- task_prompt + + if (length(context_quos) > 0) { + context_values <- lapply(context_quos, eval_tidy) + context_names <- vapply(context_quos, as_label, character(1)) + names(context_values) <- context_names + context_env <- new_environment(context_values, parent = parent.frame()) + user_context_text <- trimws(paste( + btw_this(context_env, items = context_names), + collapse = "\n" + )) + if (nzchar(user_context_text)) { + final_system_prompt <- paste( + c(final_system_prompt, "", "---", "# Additional Context", "", user_context_text), + collapse = "\n" + ) + } + } + + # Resolve client: explicit arg > task file YAML > btw defaults + if (is.null(client) && !is.null(task_config$client)) { + client <- task_config$client + } + + tools <- task_config$tools %||% btw_tools() + + # Call btw_client() to register tools, then replace the system prompt + # with the task-specific content (matches btw_task_create_* pattern) + chat_client <- btw_client( + client = client, + tools = tools + ) + chat_client$set_system_prompt(final_system_prompt) + + # Derive identity fields from task config or file basename + tool_name_raw <- task_config$name %||% fs::path_ext_remove(basename(path)) + tool_name_normalized <- validate_task_tool_name(tool_name_raw, path) + display_name <- task_config$title %||% + to_title_case(gsub("[_-]", " ", tool_name_raw)) + + if (mode == "client") { + return(chat_client) + } + + if (mode == "tool") { + task_tool_fn <- function(prompt = "") { + this_client <- chat_client$clone() + + sys_prompt <- paste0( + this_client$get_system_prompt(), + "\n\n---\n\n", + "YOU ARE NOW OPERATING IN TOOL MODE. ", + "The user cannot respond directly to you. ", + "Because you cannot talk to the user, you will need to make ", + "your own decisions using the information available to you ", + "and the best of your abilities. ", + "You may do additional exploration if needed." + ) + + this_client$set_system_prompt(sys_prompt) + + if (nzchar(prompt)) { + this_client$chat(paste0("Additional instructions: ", prompt)) + } else { + this_client$chat("Please complete the task as instructed.") + } + } + + description <- task_config$description %||% { + lines <- strsplit(task_prompt, "\n")[[1]] + first_nonempty <- trimws(lines[nzchar(trimws(lines))]) + if (length(first_nonempty) > 0) { + gsub("^#+\\s*", "", first_nonempty[1]) + } else { + paste0("Run the task defined in ", basename(path)) + } + } + + tool <- ellmer::tool( + task_tool_fn, + name = paste0("btw_task_", tool_name_normalized), + description = description, + annotations = ellmer::tool_annotations( + title = display_name, + read_only_hint = FALSE, + open_world_hint = TRUE + ), + arguments = list( + prompt = ellmer::type_string( + "Additional instructions for the task. Leave empty to proceed with default instructions.", + required = FALSE + ) + ) + ) + + if (!is.null(task_config$icon)) { + icon <- custom_icon(task_config$icon) + if (is.null(icon)) { + cli::cli_warn(c( + "Invalid icon in task file {.path {path}}", + "i" = "Ignoring {.field icon}: {.val {task_config$icon}}" + )) + } + tool@annotations$icon <- icon + } + + return(tool) + } + + if (mode == "console") { + cli::cli_text( + "Starting {.strong btw_task()} for {.file {display_name}} in live console mode." + ) + cli::cli_text( + "{cli::col_yellow(cli::symbol$play)} ", + "Say \"{.strong {cli::col_magenta('Let\\'s get started.')}}\" to begin." + ) + ellmer::live_console(chat_client) + } else { + btw_app_from_client( + client = chat_client, + messages = list(list( + role = "assistant", + content = paste0( + "\U1F4CB Hi! I'm ready to help with the ", + htmltools::htmlEscape(display_name), + " task.

", + "Say Let's get started. to begin." + ) + )) + ) + } +} + +validate_task_tool_name <- function(name, path) { + check_string(name) + + name <- trimws(name) + if (!nzchar(name)) { + cli::cli_abort(c( + "Task name cannot be empty: {.path {path}}", + "i" = "Provide a non-empty {.field name} in YAML frontmatter or use a file name with letters." + )) + } + + if (!grepl("^[a-zA-Z0-9_-]+$", name)) { + cli::cli_abort(c( + "Invalid task name {.val {name}} in {.path {path}}", + "i" = "{.field name} must contain only letters, numbers, - and _." + )) + } + + name +} diff --git a/man/btw_agent_tool.Rd b/man/btw_agent_tool.Rd index e2b3c600..0e3aa89b 100644 --- a/man/btw_agent_tool.Rd +++ b/man/btw_agent_tool.Rd @@ -65,7 +65,7 @@ from other icon packages. Supported packages:\tabular{lll}{ bsicons \tab \code{bsicons::house} \tab \code{\link[bsicons:bs_icon]{bsicons::bs_icon()}} \cr phosphoricons \tab \code{phosphoricons::house} \tab \code{\link[phosphoricons:ph]{phosphoricons::ph()}} \cr rheroicons \tab \code{rheroicons::home} \tab \code{\link[rheroicons:rheroicon]{rheroicons::rheroicon()}} \cr - tabler \tab \code{tabler::home} \tab \code{\link[tabler:tabler-components]{tabler::icon()}} \cr + tabler \tab \code{tabler::home} \tab \code{\link[tabler:icon]{tabler::icon()}} \cr shiny \tab \code{shiny::home} \tab \code{\link[shiny:icon]{shiny::icon()}} \cr } diff --git a/man/btw_task.Rd b/man/btw_task.Rd new file mode 100644 index 00000000..a173d23d --- /dev/null +++ b/man/btw_task.Rd @@ -0,0 +1,112 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/btw_task.R +\name{btw_task} +\alias{btw_task} +\title{Run a pre-formatted btw task} +\usage{ +btw_task( + path, + ..., + client = NULL, + mode = c("app", "console", "client", "tool") +) +} +\arguments{ +\item{path}{Path to the task file containing YAML configuration and prompt.} + +\item{...}{Named arguments become template variables for interpolation in the +task prompt. Unnamed arguments are treated as additional context objects +and converted to text via \code{\link[=btw]{btw()}}.} + +\item{client}{An \link[ellmer:Chat]{ellmer::Chat} client to override the task file's client +configuration. If \code{NULL}, uses the client specified in the task file's +YAML frontmatter, falling back to the default client resolution of +\code{\link[=btw_client]{btw_client()}}.} + +\item{mode}{The execution mode for the task: +\itemize{ +\item \code{"app"}: Launch interactive Shiny app (default) +\item \code{"console"}: Interactive console chat with \code{\link[ellmer:live_console]{ellmer::live_console()}} +\item \code{"client"}: Return configured \link[ellmer:Chat]{ellmer::Chat} client without running +\item \code{"tool"}: Return an \code{\link[ellmer:tool]{ellmer::tool()}} object for programmatic use +}} +} +\value{ +Depending on \code{mode}: +\itemize{ +\item \code{"app"}: Returns the chat client invisibly after launching the app +\item \code{"console"}: Returns the chat client after console interaction +\item \code{"client"}: Returns the configured chat client +\item \code{"tool"}: Returns an \code{\link[ellmer:tool]{ellmer::tool()}} object +} +} +\description{ +Runs a btw task defined in a file with YAML frontmatter configuration and +a markdown body containing the task prompt. The task file format is similar +to \code{btw.md} files, with client and tool configuration in the frontmatter and +the task instructions in the body. +\subsection{Task File Format}{ + +Task files use the same format as \code{btw.md} files: + +\if{html}{\out{
}}\preformatted{--- +client: + provider: anthropic + model: claude-sonnet-4 +tools: [docs, files] +--- + +Your task prompt here with \{\{ variable \}\} interpolation... +}\if{html}{\out{
}} +} + +\subsection{Template Variables}{ + +The task prompt body supports template variable interpolation using +\code{{{ variable }}} syntax via \code{\link[ellmer:interpolate]{ellmer::interpolate()}}. Pass named arguments +to provide values for template variables: + +\if{html}{\out{
}}\preformatted{btw_task("my-task.md", package_name = "dplyr", version = "1.1.0") +}\if{html}{\out{
}} +} + +\subsection{Additional Context}{ + +Unnamed arguments are treated as additional context and converted to text +using \code{\link[=btw]{btw()}}. This context is appended to the system prompt: + +\if{html}{\out{
}}\preformatted{btw_task("analyze.md", dataset_name = "mtcars", mtcars, my_function) +# ^-- template var ^-- additional context +}\if{html}{\out{
}} +} +} +\examples{ +# Create a simple task file +tmp_task_file <- tempfile(fileext = ".md") + +cat(file = tmp_task_file, '--- +client: anthropic/claude-sonnet-4-6 +tools: [docs, files] +--- + +Analyze the {{ package_name }} package and create a summary. +') + +# Task with template interpolation +btw_task(tmp_task_file, package_name = "dplyr", mode = "tool") + +# Include additional context +btw_task( + tmp_task_file, + package_name = "ggplot2", + mtcars, # Additional context + mode = "tool" +) + +} +\seealso{ +Other task and agent functions: +\code{\link{btw_task_create_btw_md}()}, +\code{\link{btw_task_create_readme}()} +} +\concept{task and agent functions} diff --git a/man/btw_task_create_btw_md.Rd b/man/btw_task_create_btw_md.Rd index 3b13bae2..e9ef2c90 100644 --- a/man/btw_task_create_btw_md.Rd +++ b/man/btw_task_create_btw_md.Rd @@ -59,6 +59,7 @@ withr::with_envvar(list(ANTHROPIC_API_KEY = "example"), { } \seealso{ Other task and agent functions: +\code{\link{btw_task}()}, \code{\link{btw_task_create_readme}()} } \concept{task and agent functions} diff --git a/man/btw_task_create_readme.Rd b/man/btw_task_create_readme.Rd index 37d3eada..696afdb8 100644 --- a/man/btw_task_create_readme.Rd +++ b/man/btw_task_create_readme.Rd @@ -58,6 +58,7 @@ withr::with_envvar(list(ANTHROPIC_API_KEY = "example"), { } \seealso{ Other task and agent functions: +\code{\link{btw_task}()}, \code{\link{btw_task_create_btw_md}()} } \concept{task and agent functions} diff --git a/pkgdown/_pkgdown.yml b/pkgdown/_pkgdown.yml index 953bcdd8..23afbb48 100644 --- a/pkgdown/_pkgdown.yml +++ b/pkgdown/_pkgdown.yml @@ -90,6 +90,8 @@ reference: Tasks are typically designed for interactive use (e.g. to collaboratively create a project context file), but they can also be used programmatically. contents: + - btw_task + - btw_agent_tool - starts_with("btw_task_") # TODO update docs: Agents are similar, but are designed to be used primarily as tools by other LLMs. - starts_with("btw_agent_") diff --git a/tests/testthat/fixtures/test-analyze-package.md b/tests/testthat/fixtures/test-analyze-package.md new file mode 100644 index 00000000..f5c12ea1 --- /dev/null +++ b/tests/testthat/fixtures/test-analyze-package.md @@ -0,0 +1,5 @@ +--- +tools: [docs] +--- + +Analyze the R package **{{ package_name }}**. diff --git a/tests/testthat/test-btw_task.R b/tests/testthat/test-btw_task.R new file mode 100644 index 00000000..05dec845 --- /dev/null +++ b/tests/testthat/test-btw_task.R @@ -0,0 +1,268 @@ +local_enable_tools() +withr::local_options(btw.client.quiet = TRUE) + +describe("btw_task()", { + withr::local_envvar(list(ANTHROPIC_API_KEY = "test-key")) + + it("reads and parses task files correctly", { + # Create a temporary task file + task_file <- withr::local_tempfile(fileext = ".md") + writeLines( + con = task_file, + c( + "---", + "client:", + " provider: anthropic", + " model: claude-sonnet-4", + "tools: [docs, files]", + "---", + "", + "Analyze the {{ package_name }} package.", + "Focus on {{ focus_area }}." + ) + ) + + # Test client mode + chat <- btw_task( + task_file, + package_name = "dplyr", + focus_area = "data manipulation", + mode = "client" + ) + + expect_s3_class(chat, "Chat") + + # Check that interpolation worked + sys_prompt <- chat$get_system_prompt() + expect_match(sys_prompt, "Analyze the dplyr package", fixed = TRUE) + expect_match(sys_prompt, "Focus on data manipulation", fixed = TRUE) + + # Should not contain template markers + expect_no_match(sys_prompt, "{{", fixed = TRUE) + expect_no_match(sys_prompt, "}}", fixed = TRUE) + }) + + it("handles mixed named and unnamed arguments", { + task_file <- withr::local_tempfile(fileext = ".md") + writeLines( + con = task_file, + c( + "---", + "tools: [env]", + "---", + "", + "Analyze {{ dataset_name }}." + ) + ) + + chat <- btw_task( + task_file, + dataset_name = "mtcars", # Named - template var + mode = "client" + ) + + sys_prompt <- chat$get_system_prompt() + expect_match(sys_prompt, "Analyze mtcars", fixed = TRUE) + }) + + it("appends unnamed arguments as additional context", { + task_file <- withr::local_tempfile(fileext = ".md") + writeLines( + con = task_file, + c( + "---", + "tools: false", + "---", + "", + "Analyze the data." + ) + ) + + chat <- btw_task( + task_file, + mtcars, # Unnamed - additional context + mode = "client" + ) + + sys_prompt <- chat$get_system_prompt() + expect_match(sys_prompt, "# Additional Context", fixed = TRUE) + }) + + it("creates a working tool", { + task_file <- withr::local_tempfile(fileext = ".md") + writeLines( + con = task_file, + c( + "---", + "tools: false", + "---", + "", + "Simple task: {{ action }}" + ) + ) + + tool <- btw_task( + task_file, + action = "test", + mode = "tool" + ) + + expect_s3_class(tool, "ellmer::ToolDef") + + # Check tool properties + expect_match(tool@name, "btw_task_") + expect_type(tool@description, "character") + + # Tool arguments are stored as an ArgumentSchema object + # Just check that the tool has arguments defined + expect_false(is.null(tool@arguments)) + }) + + it("uses valid task file names for tool identifiers", { + task_dir <- withr::local_tempdir() + task_file <- file.path(task_dir, "my-task-file.md") + writeLines( + con = task_file, + c( + "---", + "tools: false", + "---", + "", + "Simple task" + ) + ) + + tool <- btw_task(task_file, mode = "tool") + expect_identical(tool@name, "btw_task_my-task-file") + }) + + it("errors on invalid task names in frontmatter", { + task_file <- withr::local_tempfile(fileext = ".md") + writeLines( + con = task_file, + c( + "---", + "name: task.with.dot", + "tools: false", + "---", + "", + "Simple task" + ) + ) + + expect_error( + btw_task(task_file, mode = "tool"), + "Invalid task name" + ) + }) + + it("warns on invalid icon and falls back to NULL", { + task_file <- withr::local_tempfile(fileext = ".md") + writeLines( + con = task_file, + c( + "---", + "tools: false", + "icon: ''", + "---", + "", + "Simple task" + ) + ) + + expect_warning( + tool <- btw_task(task_file, mode = "tool"), + "Invalid icon in task file" + ) + expect_s3_class(tool, "ellmer::ToolDef") + expect_null(tool@annotations$icon) + }) + + it("errors on missing file", { + expect_error( + btw_task("nonexistent.md", mode = "client"), + "Task file not found" + ) + }) + + it("errors on empty task prompt", { + task_file <- withr::local_tempfile(fileext = ".md") + writeLines( + con = task_file, + c( + "---", + "tools: [docs]", + "---", + "" # Empty body + ) + ) + + expect_error( + btw_task(task_file, mode = "client"), + "must contain a prompt" + ) + }) + + it("handles task files without frontmatter", { + task_file <- withr::local_tempfile(fileext = ".md") + writeLines( + con = task_file, + c( + "Simple task without config.", + "Analyze {{ target }}." + ) + ) + + # Should work with defaults + chat <- btw_task( + task_file, + target = "the data", + mode = "client" + ) + + expect_s3_class(chat, "Chat") + sys_prompt <- chat$get_system_prompt() + expect_match(sys_prompt, "Analyze the data", fixed = TRUE) + }) + + it("respects client override", { + task_file <- withr::local_tempfile(fileext = ".md") + writeLines( + con = task_file, + c( + "---", + "client:", + " provider: openai", + " model: gpt-4", + "---", + "", + "Task content" + ) + ) + + # Override with different client + custom_client <- ellmer::chat_anthropic( + system_prompt = "Custom prompt" + ) + + chat <- btw_task( + task_file, + client = custom_client, + mode = "client" + ) + + # Should use the provided client + expect_identical(chat$get_provider()@name, "Anthropic") + }) +}) + +describe("btw_task() task files", { + withr::local_envvar(list(ANTHROPIC_API_KEY = "test-key")) + + it("can load a task file with template variables", { + task_file <- testthat::test_path("fixtures/test-analyze-package.md") + expect_no_error( + btw_task(task_file, package_name = "base", mode = "client") + ) + }) +})