From 385de4ac6061766b3e6927db843c0739186577e6 Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Fri, 9 Oct 2026 14:37:19 -0500 Subject: [PATCH 01/17] Prototype trusted() in R, with commons() accepting one --- pkg-r/NAMESPACE | 3 + pkg-r/R/commons.R | 62 +++--- pkg-r/R/trusted.R | 274 +++++++++++++++++++++++++ pkg-r/_pkgdown.yml | 1 + pkg-r/man/commons.Rd | 3 +- pkg-r/man/trusted.Rd | 57 +++++ pkg-r/tests/testthat/_snaps/commons.md | 4 +- pkg-r/tests/testthat/_snaps/trusted.md | 16 ++ pkg-r/tests/testthat/test-commons.R | 4 +- pkg-r/tests/testthat/test-trusted.R | 27 +++ 10 files changed, 417 insertions(+), 34 deletions(-) create mode 100644 pkg-r/R/trusted.R create mode 100644 pkg-r/man/trusted.Rd create mode 100644 pkg-r/tests/testthat/_snaps/trusted.md create mode 100644 pkg-r/tests/testthat/test-trusted.R diff --git a/pkg-r/NAMESPACE b/pkg-r/NAMESPACE index c9004cba5..2f23b634d 100644 --- a/pkg-r/NAMESPACE +++ b/pkg-r/NAMESPACE @@ -1,5 +1,7 @@ # Generated by roxygen2: do not edit by hand +S3method(print,commons_trusted_result) +S3method(print,commons_trusted_text) export(commons) export(commons_app) export(commons_server) @@ -10,6 +12,7 @@ export(measure) export(semantic_layer) export(trajectory_read) export(trajectory_review) +export(trusted) importFrom(R6,R6Class) importFrom(coro, async_generator, diff --git a/pkg-r/R/commons.R b/pkg-r/R/commons.R index ffe8a07f2..f580179c8 100644 --- a/pkg-r/R/commons.R +++ b/pkg-r/R/commons.R @@ -18,7 +18,8 @@ #' commons' prompt. #' @param data_sources A [data_source()], or a named list of them. Measures #' can take a source's connection as an argument named after the source; see -#' [semantic_layer()]. +#' [semantic_layer()]. In `commons()`, this can also be a [trusted()] object, +#' in which case `semantic_layer` and `context_layer` must be `NULL`. #' @param semantic_layer An optional [semantic_layer()]. #' @param context_layer An optional [context_layer()]. #' @param ... These dots are for future extensions and must be empty. @@ -190,10 +191,17 @@ commons <- function( ) ) } - data_sources <- as_data_sources(data_sources) - check_context_layer(context_layer) - semantic_layer <- semantic_layer %||% new_semantic_layer() - check_semantic_layer(semantic_layer) + if (inherits(data_sources, "commons_trusted")) { + if (!is.null(semantic_layer) || !is.null(context_layer)) { + cli::cli_abort( + "Pass {.arg semantic_layer} and {.arg context_layer} to {.fn trusted}, + not {.fn commons}, when {.arg data_sources} is a {.fn trusted} object." + ) + } + trusted <- data_sources + } else { + trusted <- new_trusted(data_sources, semantic_layer, context_layer) + } network <- rlang::arg_match(network) protection <- run_r_protection_mode() check_instructions(instructions) @@ -202,9 +210,7 @@ commons <- function( Commons$new( client = client, - data_sources = data_sources, - context_layer = context_layer, - semantic_layer = semantic_layer, + trusted = trusted, network = network, protection = protection, instructions = instructions, @@ -219,9 +225,7 @@ Commons <- R6::R6Class( public = list( initialize = function( client, - data_sources, - semantic_layer = NULL, - context_layer = NULL, + trusted, ..., instructions = NULL, network = c("none", "full"), @@ -235,34 +239,30 @@ Commons <- R6::R6Class( model = client$get_model_object(), echo = "none" ) - semantic_layer <- semantic_layer %||% new_semantic_layer() network <- rlang::arg_match(network) - sources <- as_data_sources(data_sources) - + state <- trusted_state_of(trusted) + sources <- state$sources + private$trusted_obj <- trusted private$sources <- sources - private$context_layer <- augment_context_layer(context_layer, sources) + private$context_layer <- state$context_layer private$first_touch <- new.env(parent = emptyenv()) - private$definitions <- definitions_registry(sources) - private$semantic_models <- semantic_models_registry(sources) - private$calculations <- calculations_registry(sources) - semantic_state <- semantic_layer_state(semantic_layer) - private$registry <- semantic_state$measures - private$fn_sources <- semantic_state$fn_sources - private$measure_provenance <- semantic_state$measure_provenance - private$measure_display <- semantic_state$measure_display - private$injections <- resolve_injections( - private$registry, - measure_injectables(sources) - ) + private$definitions <- state$definitions + private$semantic_models <- state$semantic_models + private$calculations <- state$calculations + private$registry <- state$registry + private$fn_sources <- state$fn_sources + private$measure_provenance <- state$measure_provenance + private$measure_display <- state$measure_display + private$injections <- state$injections private$tracing <- new_trajectory_tracing(log, share_with) local_commons_span( "commons_agent_create", attributes = list( "commons.agent.n_data_sources" = length(sources), - "commons.agent.has_context_layer" = !is.null(context_layer), - "commons.agent.n_measures" = length(semantic_state$measures), + "commons.agent.has_context_layer" = state$has_context_layer, + "commons.agent.n_measures" = length(state$registry), "commons.agent.n_definitions" = nrow(private$definitions$defs), "commons.agent.n_semantic_members" = nrow( private$semantic_models$members @@ -431,6 +431,9 @@ Commons <- R6::R6Class( invisible(self) } ), + active = list( + trusted = function() private$trusted_obj + ), private = list( prewarm_context = function() { layer <- private$context_layer @@ -463,6 +466,7 @@ Commons <- R6::R6Class( invisible(self) }, + trusted_obj = NULL, sources = NULL, context_layer = NULL, registry = NULL, diff --git a/pkg-r/R/trusted.R b/pkg-r/R/trusted.R new file mode 100644 index 000000000..563674fe8 --- /dev/null +++ b/pkg-r/R/trusted.R @@ -0,0 +1,274 @@ +#' Trusted calculations and context for any R session +#' +#' `trusted()` gathers a team's data sources, semantic layer, and context layer +#' into one object that code can call directly, without a model. It's the part +#' of a [commons()] agent that doesn't depend on an LLM: pass it to `commons()` +#' to build an agent, or place it in an R session that a coding agent already +#' controls. +#' +#' @inheritParams commons +#' +#' @return A `commons_trusted` object with methods: +#' +#' * `$search(query)` searches trusted calculations. +#' * `$measure(name, arguments)` runs a measure. +#' * `$metrics(metrics, dimensions, filters, where, arguments, source)` +#' computes governed metrics. +#' * `$calculation(name, arguments, source)` runs an exact trusted query. +#' * `$context(query)` searches business context. +#' * `$describe(table, source)` describes a table. +#' * `$connection(source)` returns a source's DBI connection, for queries +#' that no trusted calculation answers. +#' +#' Results from `$measure()`, `$metrics()`, and `$calculation()` record the +#' call that produced them, and print it below the result. +#' +#' @examples +#' \dontrun{ +#' tr <- trusted( +#' data_source(warehouse = con), +#' semantic_layer = sem, +#' context_layer = context_layer(files = "notes.md") +#' ) +#' tr +#' tr$search("revenue by region") +#' tr$measure("revenue_by_region", arguments = list(region = "West")) +#' +#' # The same object backs an agent: +#' agent <- commons(ellmer::chat_anthropic(), tr) +#' } +#' @export +trusted <- function(data_sources, semantic_layer = NULL, context_layer = NULL) { + new_trusted(data_sources, semantic_layer, context_layer) +} + +new_trusted <- function( + data_sources, + semantic_layer = NULL, + context_layer = NULL, + call = rlang::caller_env() +) { + data_sources <- as_data_sources(data_sources, call = call) + check_context_layer(context_layer, call = call) + semantic_layer <- semantic_layer %||% new_semantic_layer() + check_semantic_layer(semantic_layer, call = call) + Trusted$new(trusted_state(data_sources, semantic_layer, context_layer, call)) +} + +Trusted <- R6::R6Class( + "commons_trusted", + public = list( + initialize = function(state) { + private$state <- state + }, + + print = function(...) { + cat(trusted_overview(private$state), sep = "\n") + invisible(self) + }, + + search = function(query) { + s <- private$state + trusted_text(search_pool_text( + s$registry, + s$definitions, + query, + if (length(s$sources) > 1) names(s$sources) else character(), + semantic_models = s$semantic_models, + calculations = s$calculations + )) + }, + + measure = function(name, arguments = list()) { + s <- private$state + trusted_call( + list(kind = "measure", name = name, arguments = arguments), + function(handles) { + call_measure_tool( + s$registry, + name, + arguments, + injections = s$injections, + handles = handles, + sources = s$sources, + measure_provenance = s$measure_provenance, + measure_display = s$measure_display + ) + } + ) + }, + + metrics = function( + metrics, + dimensions = NULL, + filters = NULL, + where = NULL, + arguments = list(), + source = NULL + ) { + s <- private$state + trusted_call( + list( + kind = "metrics", + metrics = metrics, + dimensions = dimensions, + filters = filters, + source = source + ), + function(handles) { + call_metrics_impl( + s$definitions, + s$sources, + handles, + metrics = metrics, + dimensions = dimensions, + filters = filters, + where = where, + source_name = source, + arguments = arguments + ) + } + ) + }, + + calculation = function(name, arguments = list(), source = NULL) { + s <- private$state + trusted_call( + list(kind = "calculation", name = name, arguments = arguments), + function(handles) { + call_calculation_impl( + s$sources, + handles, + name, + arguments, + source_name = source + ) + } + ) + }, + + context = function(query) { + result <- search_context_tool(private$state$context_layer, query) + trusted_text(tool_result_text(result)) + }, + + describe = function(table, source = NULL) { + src <- resolve_sql_source(private$state$sources, source) + trusted_text(tool_result_text(describe_table_tool(src, table))) + }, + + connection = function(source = NULL) { + src <- resolve_sql_source(private$state$sources, source) + data_source_state(src)$con + } + ), + private = list(state = NULL) +) + +trusted_state_of <- function(x) object_private(x)$state + +trusted_state <- function(data_sources, semantic_layer, context_layer, call) { + semantic_state <- semantic_layer_state(semantic_layer) + list( + sources = data_sources, + context_layer = augment_context_layer(context_layer, data_sources), + has_context_layer = !is.null(context_layer), + definitions = definitions_registry(data_sources), + semantic_models = semantic_models_registry(data_sources), + calculations = calculations_registry(data_sources), + registry = semantic_state$measures, + fn_sources = semantic_state$fn_sources, + measure_provenance = semantic_state$measure_provenance, + measure_display = semantic_state$measure_display, + injections = resolve_injections( + semantic_state$measures, + measure_injectables(data_sources), + call = call + ) + ) +} + +trusted_overview <- function(state) { + tables <- unlist(lapply(state$sources, function(source) { + tables <- data_source_state(source)$tables + if (is.character(tables)) tables else names(tables) + })) + n_metrics <- nrow(registry_defs(state$definitions)) + + nrow(registry_semantic_members(state$semantic_models)) + measures <- vapply( + state$registry, + function(td) sprintf(" - %s: %s", tool_name(td), tool_description(td)), + character(1) + ) + c( + "", + sprintf( + "Sources: %s. Tables: %s.", + paste(names(state$sources) %||% "1 unnamed", collapse = ", "), + if (length(tables)) paste(tables, collapse = ", ") else "none listed" + ), + if (length(measures)) c("Measures:", measures), + if (n_metrics) sprintf("Governed metrics: %d.", n_metrics), + if (length(state$calculations)) { + sprintf("Exact trusted queries: %d.", length(state$calculations)) + }, + if (state$has_context_layer) "Business context is available.", + "", + "These calculations are the team's vetted definitions. Before writing", + "your own analysis code or SQL, look for one with `$search()` and run it", + "with `$measure()`, `$metrics()`, or `$calculation()`. When none fits,", + "check `$context()` for guidance on the approach, and say in your answer", + "which numbers came from trusted calculations and which didn't." + ) +} + +# Each call gets its own handle store, so its result is the store's only entry. +trusted_call <- function(call, run) { + handles <- new_handle_store() + run(handles) + ids <- handle_ids(handles) + if (!length(ids)) { + cli::cli_abort("{.code {call$kind}} returned nothing.") + } + value <- get_handle(handles, ids[[1]]) + attr(value, "commons_trusted_call") <- call + class(value) <- c("commons_trusted_result", class(value)) + value +} + +#' @export +print.commons_trusted_result <- function(x, ...) { + call <- attr(x, "commons_trusted_call") + attr(x, "commons_trusted_call") <- NULL + class(x) <- setdiff(class(x), "commons_trusted_result") + print(x, ...) + cat(sprintf("# trusted: %s\n", format_trusted_call(call))) + invisible(x) +} + +format_trusted_call <- function(call) { + name <- call$name %||% paste(call$metrics, collapse = ", ") + args <- call[setdiff(names(call), c("kind", "name", "metrics"))] + args <- c(args$arguments, args[names(args) != "arguments"]) + args <- Filter(length, args) + arg_text <- vapply( + names(args), + function(nm) sprintf("%s = %s", nm, deparse1(args[[nm]])), + character(1) + ) + sprintf("%s %s(%s)", call$kind, name, paste(arg_text, collapse = ", ")) +} + +tool_result_text <- function(result) { + if (S7::S7_inherits(result, ellmer::ContentToolResult)) result@value else result +} + +trusted_text <- function(text) { + structure(paste(text, collapse = "\n"), class = "commons_trusted_text") +} + +#' @export +print.commons_trusted_text <- function(x, ...) { + cat(x, "\n", sep = "") + invisible(x) +} diff --git a/pkg-r/_pkgdown.yml b/pkg-r/_pkgdown.yml index bd7567c14..d2a05133a 100644 --- a/pkg-r/_pkgdown.yml +++ b/pkg-r/_pkgdown.yml @@ -26,6 +26,7 @@ reference: - semantic_layer - measure - context_layer + - trusted - title: Shiny UI and server description: Put a commons agent inside of an interactive chat app. contents: starts_with("commons_") diff --git a/pkg-r/man/commons.Rd b/pkg-r/man/commons.Rd index 2bd156f06..7ff5a11e5 100644 --- a/pkg-r/man/commons.Rd +++ b/pkg-r/man/commons.Rd @@ -25,7 +25,8 @@ commons' prompt.} \item{data_sources}{A \code{\link[=data_source]{data_source()}}, or a named list of them. Measures can take a source's connection as an argument named after the source; see -\code{\link[=semantic_layer]{semantic_layer()}}.} +\code{\link[=semantic_layer]{semantic_layer()}}. In \code{commons()}, this can also be a \code{\link[=trusted]{trusted()}} object, +in which case \code{semantic_layer} and \code{context_layer} must be \code{NULL}.} \item{semantic_layer}{An optional \code{\link[=semantic_layer]{semantic_layer()}}.} diff --git a/pkg-r/man/trusted.Rd b/pkg-r/man/trusted.Rd new file mode 100644 index 000000000..098c1f941 --- /dev/null +++ b/pkg-r/man/trusted.Rd @@ -0,0 +1,57 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/trusted.R +\name{trusted} +\alias{trusted} +\title{Trusted calculations and context for any R session} +\usage{ +trusted(data_sources, semantic_layer = NULL, context_layer = NULL) +} +\arguments{ +\item{data_sources}{A \code{\link[=data_source]{data_source()}}, or a named list of them. Measures +can take a source's connection as an argument named after the source; see +\code{\link[=semantic_layer]{semantic_layer()}}. In \code{commons()}, this can also be a \code{\link[=trusted]{trusted()}} object, +in which case \code{semantic_layer} and \code{context_layer} must be \code{NULL}.} + +\item{semantic_layer}{An optional \code{\link[=semantic_layer]{semantic_layer()}}.} + +\item{context_layer}{An optional \code{\link[=context_layer]{context_layer()}}.} +} +\value{ +A \code{commons_trusted} object with methods: +\itemize{ +\item \verb{$search(query)} searches trusted calculations. +\item \verb{$measure(name, arguments)} runs a measure. +\item \verb{$metrics(metrics, dimensions, filters, where, arguments, source)} +computes governed metrics. +\item \verb{$calculation(name, arguments, source)} runs an exact trusted query. +\item \verb{$context(query)} searches business context. +\item \verb{$describe(table, source)} describes a table. +\item \verb{$connection(source)} returns a source's DBI connection, for queries +that no trusted calculation answers. +} + +Results from \verb{$measure()}, \verb{$metrics()}, and \verb{$calculation()} record the +call that produced them, and print it below the result. +} +\description{ +\code{trusted()} gathers a team's data sources, semantic layer, and context layer +into one object that code can call directly, without a model. It's the part +of a \code{\link[=commons]{commons()}} agent that doesn't depend on an LLM: pass it to \code{commons()} +to build an agent, or place it in an R session that a coding agent already +controls. +} +\examples{ +\dontrun{ +tr <- trusted( + data_source(warehouse = con), + semantic_layer = sem, + context_layer = context_layer(files = "notes.md") +) +tr +tr$search("revenue by region") +tr$measure("revenue_by_region", arguments = list(region = "West")) + +# The same object backs an agent: +agent <- commons(ellmer::chat_anthropic(), tr) +} +} diff --git a/pkg-r/tests/testthat/_snaps/commons.md b/pkg-r/tests/testthat/_snaps/commons.md index 0e5970e23..f59d344b9 100644 --- a/pkg-r/tests/testthat/_snaps/commons.md +++ b/pkg-r/tests/testthat/_snaps/commons.md @@ -45,7 +45,7 @@ commons(client = test_client(), data_sources = list(sales_db = test_source()), semantic_layer = layer) Condition - Error in `initialize()`: + Error in `commons()`: ! Measure "region_revenue" has undocumented argument `warehouse` matching no data source. i Available sources: "sales_db". @@ -54,7 +54,7 @@ Code commons(client = test_client(), data_sources = test_source(), semantic_layer = layer) Condition - Error in `initialize()`: + Error in `commons()`: ! Measure "region_revenue" has undocumented argument `warehouse` matching no data source. i `data_sources` has no named sources. diff --git a/pkg-r/tests/testthat/_snaps/trusted.md b/pkg-r/tests/testthat/_snaps/trusted.md new file mode 100644 index 000000000..1863919e7 --- /dev/null +++ b/pkg-r/tests/testthat/_snaps/trusted.md @@ -0,0 +1,16 @@ +# trusted() runs measures and records the call + + Code + print(n) + Output + [1] 3 + # trusted: measure order_count(region = "EMEA") + +# commons() builds an agent around a trusted() object + + Code + commons(test_client(), tr, semantic_layer = semantic_layer()) + Condition + Error in `commons()`: + ! Pass `semantic_layer` and `context_layer` to `trusted()`, not `commons()`, when `data_sources` is a `trusted()` object. + diff --git a/pkg-r/tests/testthat/test-commons.R b/pkg-r/tests/testthat/test-commons.R index b06e05873..4c67ba10a 100644 --- a/pkg-r/tests/testthat/test-commons.R +++ b/pkg-r/tests/testthat/test-commons.R @@ -45,12 +45,12 @@ test_that("commons() configures run_r network access", { test_that("run_r describes both protection modes as sandboxed", { sandboxed <- Commons$new( client = test_client(), - data_sources = list(sales_db = test_source()), + trusted = trusted(list(sales_db = test_source())), protection = "sandbox" ) guarded <- Commons$new( client = test_client(), - data_sources = list(sales_db = test_source()), + trusted = trusted(list(sales_db = test_source())), protection = "guardrails" ) sandboxed_description <- tool_description(agent_tool(sandboxed, "run_r")) diff --git a/pkg-r/tests/testthat/test-trusted.R b/pkg-r/tests/testthat/test-trusted.R new file mode 100644 index 000000000..8941438ae --- /dev/null +++ b/pkg-r/tests/testthat/test-trusted.R @@ -0,0 +1,27 @@ +test_that("trusted() runs measures and records the call", { + tr <- trusted( + list(sales_db = test_source()), + semantic_layer = semantic_layer(count_measure_tool()) + ) + + n <- tr$measure("order_count", arguments = list(region = "EMEA")) + expect_equal(unclass(n)[[1]], 3) + expect_equal( + attr(n, "commons_trusted_call"), + list(kind = "measure", name = "order_count", arguments = list(region = "EMEA")) + ) + expect_snapshot(print(n)) +}) + +test_that("commons() builds an agent around a trusted() object", { + withr::local_options(commons.allow_unsafe_fallback = TRUE) + tr <- trusted(list(sales_db = test_source())) + + agent <- commons(test_client(), tr) + expect_identical(agent$trusted, tr) + expect_s3_class(test_agent()$trusted, "commons_trusted") + expect_snapshot( + commons(test_client(), tr, semantic_layer = semantic_layer()), + error = TRUE + ) +}) From c1d97d6f5dea094580213198842173af5d299c47 Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Fri, 9 Oct 2026 15:31:02 -0500 Subject: [PATCH 02/17] Return plain values from trusted methods, without handles --- pkg-r/NAMESPACE | 1 - pkg-r/R/pool.R | 71 +++++++++------- pkg-r/R/semantic-calculations.R | 42 ++++++---- pkg-r/R/tools.R | 44 ++++++---- pkg-r/R/trusted.R | 111 ++++--------------------- pkg-r/_pkgdown.yml | 4 +- pkg-r/man/trusted.Rd | 5 +- pkg-r/tests/testthat/_snaps/trusted.md | 8 -- pkg-r/tests/testthat/test-trusted.R | 12 +-- 9 files changed, 121 insertions(+), 177 deletions(-) diff --git a/pkg-r/NAMESPACE b/pkg-r/NAMESPACE index 2f23b634d..523a3110f 100644 --- a/pkg-r/NAMESPACE +++ b/pkg-r/NAMESPACE @@ -1,6 +1,5 @@ # Generated by roxygen2: do not edit by hand -S3method(print,commons_trusted_result) S3method(print,commons_trusted_text) export(commons) export(commons_app) diff --git a/pkg-r/R/pool.R b/pkg-r/R/pool.R index 5e35c9300..054c184d2 100644 --- a/pkg-r/R/pool.R +++ b/pkg-r/R/pool.R @@ -12,6 +12,38 @@ call_metrics_impl <- function( where = NULL, source_name = NULL, arguments = "{}" +) { + query <- query_metrics( + registry, + sources, + metrics, + dimensions, + filters, + where, + source_name, + arguments + ) + metric_tool_result( + query$result, + query$sql, + handles, + metrics, + dimensions, + filters, + note = query$note, + metadata = query$metadata + ) +} + +query_metrics <- function( + registry, + sources, + metrics, + dimensions = NULL, + filters = NULL, + where = NULL, + source_name = NULL, + arguments = "{}" ) { source <- resolve_sql_source(sources, source_name) label <- source_name %||% rlang::names2(sources)[[1]] @@ -27,9 +59,8 @@ call_metrics_impl <- function( ) } if (identical(origins[[1]], "semantic_model")) { - return(call_semantic_metrics( + return(query_semantic_metrics( source, - handles, semantic_members, metrics, dimensions, @@ -112,28 +143,15 @@ call_metrics_impl <- function( } result <- source_query(source, sql) - advert <- register_handle(handles, result) dimension_defs <- on_table[ match(dim_names, on_table$name, nomatch = 0L), ] applied <- rbind(metric_defs, dimension_defs, filter_defs) applied <- applied[!duplicated(applied[c("name", "table", "source")]), ] - note <- applied_definitions_text(applied) - args <- drop_nulls(list( - metrics = metrics, - dimensions = dimensions, - filters = filters - )) - metric_tool_result( - result, - sql, - handles, - metrics, - dimensions, - filters, - note = note, - advert = advert, - args = args, + list( + result = result, + sql = sql, + note = applied_definitions_text(applied), metadata = metric_definition_metadata(metric_defs) ) } @@ -199,9 +217,8 @@ metric_origin <- function(name, definitions, semantic_members) { } } -call_semantic_metrics <- function( +query_semantic_metrics <- function( source, - handles, members, metrics, dimensions, @@ -261,14 +278,10 @@ call_semantic_metrics <- function( "Unsupported native semantic-model backend {.val {model$backend}}." ) ) - result <- source_query_bind(source, sql, unname(arguments)) - metric_tool_result( - result, - sql, - handles, - metrics, - dimensions, - filters, + list( + result = source_query_bind(source, sql, unname(arguments)), + sql = sql, + note = NULL, metadata = semantic_metric_metadata(metric_members) ) } diff --git a/pkg-r/R/semantic-calculations.R b/pkg-r/R/semantic-calculations.R index a9c506dc2..c8c7b8cd8 100644 --- a/pkg-r/R/semantic-calculations.R +++ b/pkg-r/R/semantic-calculations.R @@ -161,6 +161,31 @@ call_calculation_impl <- function( name, arguments = "{}", source_name = NULL +) { + run <- run_calculation(sources, name, arguments, source_name) + result <- run$result + prepared <- run$prepared + advert <- register_handle(handles, result) + tool_result( + paste(c(df_to_markdown(result), advert), collapse = "\n\n"), + title = "Ran a trusted calculation", + icon = maybe_icon("shield-check"), + markdown = sprintf( + "```sql\n%s\n```\n\n%s", + prepared$sql, + df_to_markdown(result) + ), + html = measure_display_html(prepared$arguments, result), + tag = "A", + show_tag = FALSE + ) +} + +run_calculation <- function( + sources, + name, + arguments = "{}", + source_name = NULL ) { source <- resolve_sql_source(sources, source_name) source_label <- source_name %||% rlang::names2(sources)[[1]] @@ -177,20 +202,9 @@ call_calculation_impl <- function( parse_json_args(arguments), data_source_state(source)$con ) - result <- source_query_bind(source, prepared$sql, prepared$bindings) - advert <- register_handle(handles, result) - tool_result( - paste(c(df_to_markdown(result), advert), collapse = "\n\n"), - title = "Ran a trusted calculation", - icon = maybe_icon("shield-check"), - markdown = sprintf( - "```sql\n%s\n```\n\n%s", - prepared$sql, - df_to_markdown(result) - ), - html = measure_display_html(prepared$arguments, result), - tag = "A", - show_tag = FALSE + list( + result = source_query_bind(source, prepared$sql, prepared$bindings), + prepared = prepared ) } diff --git a/pkg-r/R/tools.R b/pkg-r/R/tools.R index 29e341037..faaf313a5 100644 --- a/pkg-r/R/tools.R +++ b/pkg-r/R/tools.R @@ -506,26 +506,14 @@ call_measure_tool <- function( measure_provenance = list(), measure_display = list() ) { + run <- run_measure(registry, name, arguments, injections, sources) td <- registry[[name]] - if (is.null(td)) { - detail <- if (length(registry)) { - cli::format_inline("Registered measures: {.val {names(registry)}}.") - } else { - "No measures are registered." - } - cli::cli_abort(c("No measure named {.val {name}}.", i = detail)) - } + args <- run$args + value <- run$value footer <- measure_source_footer( measure_provenance[[name]] %||% character() ) metadata <- measure_metadata(td, measure_display[[name]]) - args <- validate_measure_args(td, parse_json_args(arguments)) - # A measure takes a source's connection by the source's name; a board source - # must have its pins loaded before that connection can answer a query. - for (source_name in names(injections[[name]])) { - source_ensure_all(sources[[source_name]]) - } - value <- do.call(td, c(args, injections[[name]])) if (S7::S7_inherits(value, ellmer::ContentToolResult)) { return(measure_content_tool_result( args, @@ -571,6 +559,32 @@ call_measure_tool <- function( ) } +run_measure <- function( + registry, + name, + arguments, + injections = list(), + sources = list(), + call = rlang::caller_env() +) { + td <- registry[[name]] + if (is.null(td)) { + detail <- if (length(registry)) { + cli::format_inline("Registered measures: {.val {names(registry)}}.") + } else { + "No measures are registered." + } + cli::cli_abort(c("No measure named {.val {name}}.", i = detail), call = call) + } + args <- validate_measure_args(td, parse_json_args(arguments), call = call) + # A measure takes a source's connection by the source's name; a board source + # must have its pins loaded before that connection can answer a query. + for (source_name in names(injections[[name]])) { + source_ensure_all(sources[[source_name]]) + } + list(args = args, value = do.call(td, c(args, injections[[name]]))) +} + measure_content_tool_result <- function( args, result, diff --git a/pkg-r/R/trusted.R b/pkg-r/R/trusted.R index 563674fe8..3f39d0a97 100644 --- a/pkg-r/R/trusted.R +++ b/pkg-r/R/trusted.R @@ -1,4 +1,4 @@ -#' Trusted calculations and context for any R session +#' Trusted calculations and context #' #' `trusted()` gathers a team's data sources, semantic layer, and context layer #' into one object that code can call directly, without a model. It's the part @@ -20,9 +20,6 @@ #' * `$connection(source)` returns a source's DBI connection, for queries #' that no trusted calculation answers. #' -#' Results from `$measure()`, `$metrics()`, and `$calculation()` record the -#' call that produced them, and print it below the result. -#' #' @examples #' \dontrun{ #' tr <- trusted( @@ -81,21 +78,7 @@ Trusted <- R6::R6Class( measure = function(name, arguments = list()) { s <- private$state - trusted_call( - list(kind = "measure", name = name, arguments = arguments), - function(handles) { - call_measure_tool( - s$registry, - name, - arguments, - injections = s$injections, - handles = handles, - sources = s$sources, - measure_provenance = s$measure_provenance, - measure_display = s$measure_display - ) - } - ) + run_measure(s$registry, name, arguments, s$injections, s$sources)$value }, metrics = function( @@ -107,44 +90,20 @@ Trusted <- R6::R6Class( source = NULL ) { s <- private$state - trusted_call( - list( - kind = "metrics", - metrics = metrics, - dimensions = dimensions, - filters = filters, - source = source - ), - function(handles) { - call_metrics_impl( - s$definitions, - s$sources, - handles, - metrics = metrics, - dimensions = dimensions, - filters = filters, - where = where, - source_name = source, - arguments = arguments - ) - } - ) + query_metrics( + s$definitions, + s$sources, + metrics = metrics, + dimensions = dimensions, + filters = filters, + where = where, + source_name = source, + arguments = arguments + )$result }, calculation = function(name, arguments = list(), source = NULL) { - s <- private$state - trusted_call( - list(kind = "calculation", name = name, arguments = arguments), - function(handles) { - call_calculation_impl( - s$sources, - handles, - name, - arguments, - source_name = source - ) - } - ) + run_calculation(private$state$sources, name, arguments, source)$result }, context = function(query) { @@ -213,52 +172,14 @@ trusted_overview <- function(state) { sprintf("Exact trusted queries: %d.", length(state$calculations)) }, if (state$has_context_layer) "Business context is available.", - "", - "These calculations are the team's vetted definitions. Before writing", - "your own analysis code or SQL, look for one with `$search()` and run it", + "This object provides access to trusted code and context. Before writing", + "your own analysis code or SQL, look for existing code with `$search()` and run it", "with `$measure()`, `$metrics()`, or `$calculation()`. When none fits,", - "check `$context()` for guidance on the approach, and say in your answer", + "check `$context()` for guidance on the approach. Say in your answer", "which numbers came from trusted calculations and which didn't." ) } -# Each call gets its own handle store, so its result is the store's only entry. -trusted_call <- function(call, run) { - handles <- new_handle_store() - run(handles) - ids <- handle_ids(handles) - if (!length(ids)) { - cli::cli_abort("{.code {call$kind}} returned nothing.") - } - value <- get_handle(handles, ids[[1]]) - attr(value, "commons_trusted_call") <- call - class(value) <- c("commons_trusted_result", class(value)) - value -} - -#' @export -print.commons_trusted_result <- function(x, ...) { - call <- attr(x, "commons_trusted_call") - attr(x, "commons_trusted_call") <- NULL - class(x) <- setdiff(class(x), "commons_trusted_result") - print(x, ...) - cat(sprintf("# trusted: %s\n", format_trusted_call(call))) - invisible(x) -} - -format_trusted_call <- function(call) { - name <- call$name %||% paste(call$metrics, collapse = ", ") - args <- call[setdiff(names(call), c("kind", "name", "metrics"))] - args <- c(args$arguments, args[names(args) != "arguments"]) - args <- Filter(length, args) - arg_text <- vapply( - names(args), - function(nm) sprintf("%s = %s", nm, deparse1(args[[nm]])), - character(1) - ) - sprintf("%s %s(%s)", call$kind, name, paste(arg_text, collapse = ", ")) -} - tool_result_text <- function(result) { if (S7::S7_inherits(result, ellmer::ContentToolResult)) result@value else result } diff --git a/pkg-r/_pkgdown.yml b/pkg-r/_pkgdown.yml index d2a05133a..ca314d64a 100644 --- a/pkg-r/_pkgdown.yml +++ b/pkg-r/_pkgdown.yml @@ -19,14 +19,14 @@ navbar: reference: - title: Main functions - description: The main agent constructor, plus the building blocks for a commons agent. + description: The main constructor, plus the building blocks for a commons agent. contents: + - trusted - commons - data_source - semantic_layer - measure - context_layer - - trusted - title: Shiny UI and server description: Put a commons agent inside of an interactive chat app. contents: starts_with("commons_") diff --git a/pkg-r/man/trusted.Rd b/pkg-r/man/trusted.Rd index 098c1f941..5914f10da 100644 --- a/pkg-r/man/trusted.Rd +++ b/pkg-r/man/trusted.Rd @@ -2,7 +2,7 @@ % Please edit documentation in R/trusted.R \name{trusted} \alias{trusted} -\title{Trusted calculations and context for any R session} +\title{Trusted calculations and context} \usage{ trusted(data_sources, semantic_layer = NULL, context_layer = NULL) } @@ -29,9 +29,6 @@ computes governed metrics. \item \verb{$connection(source)} returns a source's DBI connection, for queries that no trusted calculation answers. } - -Results from \verb{$measure()}, \verb{$metrics()}, and \verb{$calculation()} record the -call that produced them, and print it below the result. } \description{ \code{trusted()} gathers a team's data sources, semantic layer, and context layer diff --git a/pkg-r/tests/testthat/_snaps/trusted.md b/pkg-r/tests/testthat/_snaps/trusted.md index 1863919e7..771eed606 100644 --- a/pkg-r/tests/testthat/_snaps/trusted.md +++ b/pkg-r/tests/testthat/_snaps/trusted.md @@ -1,11 +1,3 @@ -# trusted() runs measures and records the call - - Code - print(n) - Output - [1] 3 - # trusted: measure order_count(region = "EMEA") - # commons() builds an agent around a trusted() object Code diff --git a/pkg-r/tests/testthat/test-trusted.R b/pkg-r/tests/testthat/test-trusted.R index 8941438ae..60538338d 100644 --- a/pkg-r/tests/testthat/test-trusted.R +++ b/pkg-r/tests/testthat/test-trusted.R @@ -1,16 +1,10 @@ -test_that("trusted() runs measures and records the call", { +test_that("trusted() runs measures", { tr <- trusted( - list(sales_db = test_source()), + data_sources = list(sales_db = test_source()), semantic_layer = semantic_layer(count_measure_tool()) ) - n <- tr$measure("order_count", arguments = list(region = "EMEA")) - expect_equal(unclass(n)[[1]], 3) - expect_equal( - attr(n, "commons_trusted_call"), - list(kind = "measure", name = "order_count", arguments = list(region = "EMEA")) - ) - expect_snapshot(print(n)) + expect_equal(tr$measure("order_count", arguments = list(region = "EMEA")), 3) }) test_that("commons() builds an agent around a trusted() object", { From f96fafe9577686087bdc5f7c91ccb4f16a638d44 Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Fri, 23 Oct 2026 09:03:02 -0500 Subject: [PATCH 03/17] Document using trusted() with coding agents through MCP Console --- pkg-r/R/trusted.R | 39 +++++++++++++++++++++++++++++++++++++++ pkg-r/man/trusted.Rd | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/pkg-r/R/trusted.R b/pkg-r/R/trusted.R index 3f39d0a97..6c2ce176d 100644 --- a/pkg-r/R/trusted.R +++ b/pkg-r/R/trusted.R @@ -20,6 +20,45 @@ #' * `$connection(source)` returns a source's DBI connection, for queries #' that no trusted calculation answers. #' +#' @section Using commons with coding agents: +#' A coding agent with an R session, like Posit Assistant, can use a +#' `trusted` object placed in that session directly. Agents without an +#' interactive REPL, like Claude Code or Codex, can reach one through +#' [MCP Console](https://t-kalinowski.github.io/mcp-console/), which gives +#' them a persistent R session. Build `trusted` in the session's startup code +#' and tell the agent about it in the tool description. +#' +#' A configured description replaces the one MCP Console generates, so start +#' from an export of the resolved configuration, run from the project +#' directory: +#' +#' ```bash +#' mcp-console config resolve --full > .agents/console/config.yaml +#' ``` +#' +#' Then add startup code that builds `trusted`, and add a sentence about it to +#' the end of the exported description: +#' +#' ```yaml +#' startup: +#' language: r +#' code: | +#' trusted <- acme.metrics::trusted() +#' .console$sql_connection(trusted$connection()) +#' tool: +#' description: |- +#' # ...the exported description, unchanged... +#' +#' The R session has an object called `trusted` that holds this team's +#' vetted calculations and business context. Before analyzing data, run +#' `trusted` to print it, and use its calculations where they apply. +#' ``` +#' +#' Here, `acme.metrics::trusted()` stands for whatever code builds the team's +#' `trusted` object. Some clients, like Claude Code, show the tool description +#' only once the agent loads the tool, so add the same sentence to +#' `tool.instructions` as well. +#' #' @examples #' \dontrun{ #' tr <- trusted( diff --git a/pkg-r/man/trusted.Rd b/pkg-r/man/trusted.Rd index 5914f10da..8ce66d259 100644 --- a/pkg-r/man/trusted.Rd +++ b/pkg-r/man/trusted.Rd @@ -37,6 +37,45 @@ of a \code{\link[=commons]{commons()}} agent that doesn't depend on an LLM: pass to build an agent, or place it in an R session that a coding agent already controls. } +\section{Using commons with coding agents}{ + +A coding agent with an R session, like Posit Assistant, can use a +\code{trusted} object placed in that session directly. Agents without an +interactive REPL, like Claude Code or Codex, can reach one through +\href{https://t-kalinowski.github.io/mcp-console/}{MCP Console}, which gives +them a persistent R session. Build \code{trusted} in the session's startup code +and tell the agent about it in the tool description. + +A configured description replaces the one MCP Console generates, so start +from an export of the resolved configuration, run from the project +directory: + +\if{html}{\out{
}}\preformatted{mcp-console config resolve --full > .agents/console/config.yaml +}\if{html}{\out{
}} + +Then add startup code that builds \code{trusted}, and add a sentence about it to +the end of the exported description: + +\if{html}{\out{
}}\preformatted{startup: + language: r + code: | + trusted <- acme.metrics::trusted() + .console$sql_connection(trusted$connection()) +tool: + description: |- + # ...the exported description, unchanged... + + The R session has an object called `trusted` that holds this team's + vetted calculations and business context. Before analyzing data, run + `trusted` to print it, and use its calculations where they apply. +}\if{html}{\out{
}} + +Here, \code{acme.metrics::trusted()} stands for whatever code builds the team's +\code{trusted} object. Some clients, like Claude Code, show the tool description +only once the agent loads the tool, so add the same sentence to +\code{tool.instructions} as well. +} + \examples{ \dontrun{ tr <- trusted( From c7e04754db5b5f2895355a8c794d69a39c18c786 Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Fri, 23 Oct 2026 09:35:46 -0500 Subject: [PATCH 04/17] Point trusted's printout at $search() and a $context() query --- pkg-r/R/trusted.R | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg-r/R/trusted.R b/pkg-r/R/trusted.R index 6c2ce176d..2c5a07c12 100644 --- a/pkg-r/R/trusted.R +++ b/pkg-r/R/trusted.R @@ -206,15 +206,20 @@ trusted_overview <- function(state) { if (length(tables)) paste(tables, collapse = ", ") else "none listed" ), if (length(measures)) c("Measures:", measures), - if (n_metrics) sprintf("Governed metrics: %d.", n_metrics), + if (n_metrics) { + sprintf("Governed metrics: %d. Find them with `$search()`.", n_metrics) + }, if (length(state$calculations)) { - sprintf("Exact trusted queries: %d.", length(state$calculations)) + sprintf( + "Exact trusted queries: %d. Find them with `$search()`.", + length(state$calculations) + ) }, if (state$has_context_layer) "Business context is available.", "This object provides access to trusted code and context. Before writing", "your own analysis code or SQL, look for existing code with `$search()` and run it", "with `$measure()`, `$metrics()`, or `$calculation()`. When none fits,", - "check `$context()` for guidance on the approach. Say in your answer", + "check `$context(\"your query\")` for guidance on the approach. Say in your answer", "which numbers came from trusted calculations and which didn't." ) } From 192166ba5000895b821ce46db4420e292d454a18 Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Fri, 23 Oct 2026 09:41:46 -0500 Subject: [PATCH 05/17] Phrase trusted's search results as its own methods, not agent tools --- pkg-r/R/pool.R | 60 +++++++++++++++++++------- pkg-r/R/semantic-calculations.R | 16 +++++-- pkg-r/R/semantic-models.R | 24 ++++++----- pkg-r/R/trusted.R | 3 +- pkg-r/tests/testthat/_snaps/trusted.md | 30 +++++++++++++ pkg-r/tests/testthat/test-trusted.R | 9 ++++ 6 files changed, 113 insertions(+), 29 deletions(-) diff --git a/pkg-r/R/pool.R b/pkg-r/R/pool.R index 6a9a9e27a..08972c6b7 100644 --- a/pkg-r/R/pool.R +++ b/pkg-r/R/pool.R @@ -510,6 +510,7 @@ compile_where_triple <- function(triple, columns, con) { # One discovery surface over the whole pool: measures and definitions ranked # together, so the model doesn't have to guess which kind holds its answer. +# `methods` phrases usage as `trusted()` methods rather than agent tools. search_pool_text <- function( measures, registry, @@ -518,8 +519,11 @@ search_pool_text <- function( semantic_models = NULL, calculations = list(), measure_titles = FALSE, - trusted_only = FALSE + trusted_only = FALSE, + methods = FALSE ) { + # Outside commons, there's no run_sql to expand definition tokens. + trusted_only <- trusted_only || methods defs <- registry_defs(registry) semantic_models <- semantic_models %||% list(members = no_semantic_members) semantic_members <- registry_semantic_members(semantic_models) @@ -599,16 +603,24 @@ search_pool_text <- function( function(hit) { if (hit <= length(measures)) { td <- measures[[hit]] - measure_schema_text( + text <- measure_schema_text( td, source_names = source_names, heading = if (measure_titles) tool_title(td) else tool_name(td) ) + if (methods) { + text <- paste0( + text, + sprintf("\nRun with `$measure(\"%s\")`.", tool_name(td)) + ) + } + text } else if (hit <= length(measures) + nrow(defs)) { definition_pool_text( defs[hit - length(measures), ], defs, - trusted_only = trusted_only + trusted_only = trusted_only, + methods = methods ) } else if ( hit <= length(measures) + nrow(defs) + nrow(semantic_members) @@ -624,7 +636,8 @@ search_pool_text <- function( , drop = FALSE ] - ) + ), + methods = methods ) } else if ( hit <= length(measures) + @@ -638,7 +651,8 @@ search_pool_text <- function( , drop = FALSE ], - source_names + source_names, + methods = methods ) } else { calculation_pool_text( @@ -649,7 +663,8 @@ search_pool_text <- function( nrow(semantic_members) - nrow(semantic_stubs) ]], - source_names + source_names, + methods = methods ) } }, @@ -660,19 +675,29 @@ search_pool_text <- function( # Without run_sql, a definition is usable only through call_metrics: a metric # directly, and a filter or derived definition beside a metric on its table. -definition_trusted_usage <- function(def, table_has_metrics) { +definition_trusted_usage <- function(def, table_has_metrics, methods = FALSE) { if (def$mixed_grain || (!identical(def$kind, "metric") && !table_has_metrics)) { return("No trusted calculation can use this definition.") } + call_metrics <- if (methods) "`$metrics()`" else "call_metrics" switch( def$kind, - metric = sprintf("Query with call_metrics (metrics = [\"%s\"]).", def$name), - filter = "Use as a call_metrics filter or dimension.", - "Use as a call_metrics dimension." + metric = if (methods) { + sprintf("Query with `$metrics(\"%s\")`.", def$name) + } else { + sprintf("Query with call_metrics (metrics = [\"%s\"]).", def$name) + }, + filter = sprintf("Use as a %s filter or dimension.", call_metrics), + sprintf("Use as a %s dimension.", call_metrics) ) } -semantic_stub_pool_text <- function(stub, source_names = character()) { +semantic_stub_pool_text <- function( + stub, + source_names = character(), + methods = FALSE +) { + describe <- if (methods) "`$describe()`" else "`describe_table`" backend <- switch( stub$backend[[1]], snowflake_semantic_view = "Snowflake semantic view", @@ -689,11 +714,11 @@ semantic_stub_pool_text <- function(stub, source_names = character()) { "### %s --- %s\n%s%s\n", if (identical(stub$backend[[1]], "snowflake_semantic_view")) { paste( - "Inspect with `describe_table` to find its public members and", + "Inspect with", describe, "to find its public members and", "verified queries." ) } else { - "Inspect with `describe_table` to find its public members." + paste("Inspect with", describe, "to find its public members.") } ), stub$model[[1]], @@ -703,7 +728,12 @@ semantic_stub_pool_text <- function(stub, source_names = character()) { ) } -definition_pool_text <- function(def, defs, trusted_only = FALSE) { +definition_pool_text <- function( + def, + defs, + trusted_only = FALSE, + methods = FALSE +) { kind <- def$kind table_has_metrics <- any( defs$source == def$source & @@ -711,7 +741,7 @@ definition_pool_text <- function(def, defs, trusted_only = FALSE) { defs$kind == "metric" ) invoke <- if (trusted_only) { - definition_trusted_usage(def, table_has_metrics) + definition_trusted_usage(def, table_has_metrics, methods = methods) } else if (def$mixed_grain) { sprintf( "Use `{{%s}}` only in run_sql with manually grain-correct query structure.", diff --git a/pkg-r/R/semantic-calculations.R b/pkg-r/R/semantic-calculations.R index c8c7b8cd8..6c628b5a6 100644 --- a/pkg-r/R/semantic-calculations.R +++ b/pkg-r/R/semantic-calculations.R @@ -432,7 +432,11 @@ calculation_tokens <- function(sql) { substr(tokens, 3L, nchar(tokens) - 2L) } -calculation_pool_text <- function(calculation, source_names = character()) { +calculation_pool_text <- function( + calculation, + source_names = character(), + methods = FALSE +) { arguments <- calculation$arguments argument_text <- if (length(arguments)) { paste(vapply(arguments, function(argument) { @@ -466,11 +470,17 @@ calculation_pool_text <- function(calculation, source_names = character()) { } else { "" } + run <- if (methods) { + sprintf("`$calculation(\"%s\")`", calculation$key) + } else { + "`call_calculation`" + } sprintf( - "### %s --- trusted query\n%s\nArguments: %s.%s\nRun with `call_calculation`.", + "### %s --- trusted query\n%s\nArguments: %s.%s\nRun with %s.", calculation$key, calculation$description, argument_text, - source + source, + run ) } diff --git a/pkg-r/R/semantic-models.R b/pkg-r/R/semantic-models.R index a89170ff5..ccd7f7bdd 100644 --- a/pkg-r/R/semantic-models.R +++ b/pkg-r/R/semantic-models.R @@ -756,8 +756,10 @@ semantic_member_pool_text <- function( member, members, source_names = character(), - parameters = list() + parameters = list(), + methods = FALSE ) { + call_metrics <- if (methods) "`$metrics()`" else "call_metrics" reference <- if (nrow(semantic_member_candidates(member$name[[1]], members)) == 1L) { member$name[[1]] } else { @@ -774,28 +776,30 @@ semantic_member_pool_text <- function( detail ), semantic_member_sources_line(member, source_names), - semantic_parameters_pool_text(parameters), + semantic_parameters_pool_text(parameters, methods = methods), switch( member$kind[[1]], - metric = sprintf( - "Query with call_metrics (metrics = [\"%s\"]).", - reference - ), + metric = if (methods) { + sprintf("Query with `$metrics(\"%s\")`.", reference) + } else { + sprintf("Query with call_metrics (metrics = [\"%s\"]).", reference) + }, dimension = sprintf( - "Use as a call_metrics dimension: %s.", + "Use as a %s dimension: %s.", + call_metrics, reference ), NULL ), if (isTRUE(member$filter[[1]])) { - sprintf("May be applied as a call_metrics filter: %s.", reference) + sprintf("May be applied as a %s filter: %s.", call_metrics, reference) } ), collapse = "\n" ) } -semantic_parameters_pool_text <- function(parameters) { +semantic_parameters_pool_text <- function(parameters, methods = FALSE) { if (length(parameters) == 0L) { return(NULL) } @@ -817,7 +821,7 @@ semantic_parameters_pool_text <- function(parameters) { ) }, character(1)) paste0( - "call_metrics arguments JSON: ", + if (methods) "`$metrics()` arguments: " else "call_metrics arguments JSON: ", paste(entries, collapse = ", "), "." ) diff --git a/pkg-r/R/trusted.R b/pkg-r/R/trusted.R index 2c5a07c12..53be03d60 100644 --- a/pkg-r/R/trusted.R +++ b/pkg-r/R/trusted.R @@ -111,7 +111,8 @@ Trusted <- R6::R6Class( query, if (length(s$sources) > 1) names(s$sources) else character(), semantic_models = s$semantic_models, - calculations = s$calculations + calculations = s$calculations, + methods = TRUE )) }, diff --git a/pkg-r/tests/testthat/_snaps/trusted.md b/pkg-r/tests/testthat/_snaps/trusted.md index 771eed606..c803d827e 100644 --- a/pkg-r/tests/testthat/_snaps/trusted.md +++ b/pkg-r/tests/testthat/_snaps/trusted.md @@ -6,3 +6,33 @@ Error in `commons()`: ! Pass `semantic_layer` and `context_layer` to `trusted()`, not `commons()`, when `data_sources` is a `trusted()` object. +# $search() names the trusted object's methods + + Code + tr$search("big revenue region orders") + Output + ### {{big_revenue}} --- metric on table `sales` + Revenue over big EMEA orders. + Selected SQL(duckdb): `(sum(CASE WHEN ("region" = 'EMEA') AND "revenue" > 600 THEN "revenue" ELSE 0 END))`. + Translation notes: DuckDB compares a NaN as equal to itself and greater than every number, where data-dict answers false; a row holding one passes here and is reported there. DuckDB sums integers at 128 bits, so a total data-dict reports as an overflow (D09) may succeed. + Query with `$metrics("big_revenue")`. + Filters and derived definitions on this table: {{emea}} (filter), {{region_band}} (derived). + + ### order_count + Count orders, optionally filtered by region and a revenue ceiling. + + arguments: + - region (array of {Americas, APAC, EMEA}, optional) + - revenue_under (number, optional) + Run with `$measure("order_count")`. + + ### {{emea}} --- filter on table `sales` + EMEA rows only. + Selected SQL(duckdb): `("region" = 'EMEA')`. + Use as a `$metrics()` filter or dimension. + + ### {{region_band}} --- derived on table `sales` + Coarse region grouping. + Selected SQL(duckdb): `(CASE WHEN ("region" = 'EMEA') THEN 'east' ELSE 'west' END)`. + Use as a `$metrics()` dimension. + diff --git a/pkg-r/tests/testthat/test-trusted.R b/pkg-r/tests/testthat/test-trusted.R index 60538338d..ee7f71184 100644 --- a/pkg-r/tests/testthat/test-trusted.R +++ b/pkg-r/tests/testthat/test-trusted.R @@ -19,3 +19,12 @@ test_that("commons() builds an agent around a trusted() object", { error = TRUE ) }) + +test_that("$search() names the trusted object's methods", { + tr <- trusted( + data_sources = list(sales_db = definitions_source()), + semantic_layer = semantic_layer(count_measure_tool()) + ) + + expect_snapshot(tr$search("big revenue region orders")) +}) From fc23bec4a57cf7c0d667435088590dabd5679cee Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Fri, 23 Oct 2026 09:54:48 -0500 Subject: [PATCH 06/17] Accept SQL-like strings and unnamed triples for trusted's `where` --- pkg-r/R/trusted.R | 68 +++++++++++++++++++++++++- pkg-r/man/trusted.Rd | 5 +- pkg-r/tests/testthat/_snaps/trusted.md | 17 +++++++ pkg-r/tests/testthat/test-trusted.R | 32 ++++++++++++ 4 files changed, 119 insertions(+), 3 deletions(-) diff --git a/pkg-r/R/trusted.R b/pkg-r/R/trusted.R index 53be03d60..c76c82bde 100644 --- a/pkg-r/R/trusted.R +++ b/pkg-r/R/trusted.R @@ -13,7 +13,10 @@ #' * `$search(query)` searches trusted calculations. #' * `$measure(name, arguments)` runs a measure. #' * `$metrics(metrics, dimensions, filters, where, arguments, source)` -#' computes governed metrics. +#' computes governed metrics. `where` takes simple conditions on documented +#' columns, either as a string like +#' `"project = 'dplyr' AND date >= '2026-06-01'"` or as a list of +#' `c(column, op, value)` triples. #' * `$calculation(name, arguments, source)` runs an exact trusted query. #' * `$context(query)` searches business context. #' * `$describe(table, source)` describes a table. @@ -136,7 +139,7 @@ Trusted <- R6::R6Class( metrics = metrics, dimensions = dimensions, filters = filters, - where = where, + where = as_where_triples(where), source_name = source, arguments = arguments )$result @@ -238,3 +241,64 @@ print.commons_trusted_text <- function(x, ...) { cat(x, "\n", sep = "") invisible(x) } + +# Accepts the shapes models write first: a SQL-like string or unnamed triples. +as_where_triples <- function(where, call = rlang::caller_env()) { + if (is.null(where) || is.data.frame(where) || !is.null(names(where))) { + return(where) + } + if (rlang::is_string(where)) { + return(parse_where_string(where, call = call)) + } + if (is.character(where) && length(where) == 3) { + where <- list(where) + } + lapply(where, function(triple) { + if (!is.null(names(triple))) { + return(triple) + } + if (length(triple) != 3) { + abort_where(call) + } + rlang::set_names(as.list(triple), c("column", "op", "value")) + }) +} + +parse_where_string <- function(where, call = rlang::caller_env()) { + condition <- paste0( + "^\\s*\"?([A-Za-z_][A-Za-z0-9_]*)\"?\\s*(<=|>=|!=|<>|=|<|>)\\s*", + "('(?:[^']|'')*'|-?[0-9]+(?:\\.[0-9]+)?)\\s*(?:(?i:AND)\\s+|$)" + ) + rest <- where + triples <- list() + while (nzchar(trimws(rest))) { + match <- regmatches(rest, regexec(condition, rest, perl = TRUE))[[1]] + if (length(match) == 0) { + abort_where(call) + } + value <- match[[4]] + if (startsWith(value, "'")) { + value <- gsub("''", "'", substr(value, 2, nchar(value) - 1)) + } + op <- if (identical(match[[3]], "<>")) "!=" else match[[3]] + triples[[length(triples) + 1]] <- list( + column = match[[2]], + op = op, + value = value + ) + rest <- substring(rest, nchar(match[[1]]) + 1) + } + triples +} + +abort_where <- function(call) { + cli::cli_abort( + c( + "{.arg where} must be simple conditions on documented columns.", + i = "Pass a string like {.code \"project = 'dplyr' AND date >= '2026-06-01'\"}, + or a list of {.code c(column, op, value)} triples.", + i = "Operators: {.code {where_ops}}, joined by {.code AND}." + ), + call = call + ) +} diff --git a/pkg-r/man/trusted.Rd b/pkg-r/man/trusted.Rd index 8ce66d259..271398c83 100644 --- a/pkg-r/man/trusted.Rd +++ b/pkg-r/man/trusted.Rd @@ -22,7 +22,10 @@ A \code{commons_trusted} object with methods: \item \verb{$search(query)} searches trusted calculations. \item \verb{$measure(name, arguments)} runs a measure. \item \verb{$metrics(metrics, dimensions, filters, where, arguments, source)} -computes governed metrics. +computes governed metrics. \code{where} takes simple conditions on documented +columns, either as a string like +\code{"project = 'dplyr' AND date >= '2026-06-01'"} or as a list of +\code{c(column, op, value)} triples. \item \verb{$calculation(name, arguments, source)} runs an exact trusted query. \item \verb{$context(query)} searches business context. \item \verb{$describe(table, source)} describes a table. diff --git a/pkg-r/tests/testthat/_snaps/trusted.md b/pkg-r/tests/testthat/_snaps/trusted.md index c803d827e..9cd12b518 100644 --- a/pkg-r/tests/testthat/_snaps/trusted.md +++ b/pkg-r/tests/testthat/_snaps/trusted.md @@ -36,3 +36,20 @@ Selected SQL(duckdb): `(CASE WHEN ("region" = 'EMEA') THEN 'east' ELSE 'west' END)`. Use as a `$metrics()` dimension. +# $metrics() accepts `where` as a string or unnamed triples + + Code + tr$metrics("big_revenue", where = "region IN ('EMEA', 'APAC')") + Condition + Error in `tr$metrics()`: + ! `where` must be simple conditions on documented columns. + i Pass a string like `"project = 'dplyr' AND date >= '2026-06-01'"`, or a list of `c(column, op, value)` triples. + i Operators: `=`, `!=`, `<`, `<=`, `>`, and `>=`, joined by `AND`. + Code + tr$metrics("big_revenue", where = list(c("region", "EMEA"))) + Condition + Error in `tr$metrics()`: + ! `where` must be simple conditions on documented columns. + i Pass a string like `"project = 'dplyr' AND date >= '2026-06-01'"`, or a list of `c(column, op, value)` triples. + i Operators: `=`, `!=`, `<`, `<=`, `>`, and `>=`, joined by `AND`. + diff --git a/pkg-r/tests/testthat/test-trusted.R b/pkg-r/tests/testthat/test-trusted.R index ee7f71184..c5cf03b6f 100644 --- a/pkg-r/tests/testthat/test-trusted.R +++ b/pkg-r/tests/testthat/test-trusted.R @@ -28,3 +28,35 @@ test_that("$search() names the trusted object's methods", { expect_snapshot(tr$search("big revenue region orders")) }) + +test_that("$metrics() accepts `where` as a string or unnamed triples", { + tr <- trusted(list(sales_db = definitions_source())) + emea <- tr$metrics("big_revenue", where = list( + list(column = "region", op = "=", value = "EMEA"), + list(column = "revenue", op = ">", value = 0) + )) + + expect_equal( + tr$metrics("big_revenue", where = "region = 'EMEA' and revenue > 0"), + emea + ) + expect_equal( + tr$metrics( + "big_revenue", + where = list(c("region", "=", "EMEA"), list("revenue", ">", 0)) + ), + emea + ) + expect_equal( + tr$metrics("big_revenue", where = c("region", "=", "EMEA")), + tr$metrics("big_revenue", where = "region = 'EMEA'") + ) + expect_equal( + as_where_triples("name <> 'O''Brien'")[[1]], + list(column = "name", op = "!=", value = "O'Brien") + ) + expect_snapshot(error = TRUE, { + tr$metrics("big_revenue", where = "region IN ('EMEA', 'APAC')") + tr$metrics("big_revenue", where = list(c("region", "EMEA"))) + }) +}) From b2ebb796f49d4ec23eb5161299d308ff7ac1a83f Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Fri, 23 Oct 2026 10:08:34 -0500 Subject: [PATCH 07/17] List trusted's method signatures and document setup per coding agent --- pkg-r/R/trusted.R | 82 ++++++++++++++++++++------ pkg-r/man/trusted.Rd | 56 ++++++++++++------ pkg-r/tests/testthat/_snaps/trusted.md | 24 ++++++++ pkg-r/tests/testthat/test-trusted.R | 9 +++ 4 files changed, 135 insertions(+), 36 deletions(-) diff --git a/pkg-r/R/trusted.R b/pkg-r/R/trusted.R index c76c82bde..8d585f84d 100644 --- a/pkg-r/R/trusted.R +++ b/pkg-r/R/trusted.R @@ -24,23 +24,46 @@ #' that no trusted calculation answers. #' #' @section Using commons with coding agents: -#' A coding agent with an R session, like Posit Assistant, can use a -#' `trusted` object placed in that session directly. Agents without an -#' interactive REPL, like Claude Code or Codex, can reach one through -#' [MCP Console](https://t-kalinowski.github.io/mcp-console/), which gives -#' them a persistent R session. Build `trusted` in the session's startup code -#' and tell the agent about it in the tool description. +#' A coding agent can use a `trusted` object in an R session it controls. In +#' each setup below, code builds `trusted` when the session starts and a +#' sentence tells the agent about it. Here, `acme.metrics::trusted()` stands +#' for whatever code builds the team's `trusted` object. #' -#' A configured description replaces the one MCP Console generates, so start -#' from an export of the resolved configuration, run from the project -#' directory: +#' Posit Assistant runs code in an R session, so build `trusted` in the +#' project's `.Rprofile`: +#' +#' ```r +#' trusted <- acme.metrics::trusted() +#' ``` +#' +#' Posit Assistant doesn't always list the session's variables for the model, +#' so tell it about `trusted` in the project's `AGENTS.md`: +#' +#' ```markdown +#' The R session has an object called `trusted` that holds this team's vetted +#' calculations and business context. Before analyzing data, run +#' `print(trusted)`, and use its calculations where they apply. +#' ``` +#' +#' Claude Code and Codex have no persistent R session of their own. They can +#' get one through [MCP Console](https://t-kalinowski.github.io/mcp-console/). +#' Register it for the project, from the project directory: #' #' ```bash -#' mcp-console config resolve --full > .agents/console/config.yaml +#' uvx mcp-console configure-client claude --directory . +#' uvx mcp-console configure-client codex --directory . #' ``` #' -#' Then add startup code that builds `trusted`, and add a sentence about it to -#' the end of the exported description: +#' Then configure MCP Console to build `trusted` at startup and to mention it +#' in its tool description. A configured description replaces the one MCP +#' Console generates, so start from an export of the resolved configuration: +#' +#' ```bash +#' uvx mcp-console config resolve --full > .agents/console/config.yaml +#' ``` +#' +#' In that file, set the startup code, and add the same sentence to the end of +#' the exported description: #' #' ```yaml #' startup: @@ -54,13 +77,11 @@ #' #' The R session has an object called `trusted` that holds this team's #' vetted calculations and business context. Before analyzing data, run -#' `trusted` to print it, and use its calculations where they apply. +#' `print(trusted)`, and use its calculations where they apply. #' ``` #' -#' Here, `acme.metrics::trusted()` stands for whatever code builds the team's -#' `trusted` object. Some clients, like Claude Code, show the tool description -#' only once the agent loads the tool, so add the same sentence to -#' `tool.instructions` as well. +#' Claude Code shows the tool description only once the agent loads the tool, +#' so add the sentence to the end of `tool.instructions` as well. #' #' @examples #' \dontrun{ @@ -220,6 +241,8 @@ trusted_overview <- function(state) { ) }, if (state$has_context_layer) "Business context is available.", + "Methods:", + trusted_method_signatures(), "This object provides access to trusted code and context. Before writing", "your own analysis code or SQL, look for existing code with `$search()` and run it", "with `$measure()`, `$metrics()`, or `$calculation()`. When none fits,", @@ -228,6 +251,31 @@ trusted_overview <- function(state) { ) } +trusted_method_signatures <- function() { + methods <- Trusted$public_methods + methods <- methods[setdiff(names(methods), c("initialize", "print", "clone"))] + vapply( + names(methods), + function(name) { + formals <- formals(methods[[name]]) + args <- vapply( + names(formals), + function(arg) { + if (rlang::is_missing(formals[[arg]])) { + arg + } else { + paste(arg, "=", deparse(formals[[arg]])) + } + }, + character(1) + ) + sprintf(" $%s(%s)", name, paste(args, collapse = ", ")) + }, + character(1), + USE.NAMES = FALSE + ) +} + tool_result_text <- function(result) { if (S7::S7_inherits(result, ellmer::ContentToolResult)) result@value else result } diff --git a/pkg-r/man/trusted.Rd b/pkg-r/man/trusted.Rd index 271398c83..e70953b11 100644 --- a/pkg-r/man/trusted.Rd +++ b/pkg-r/man/trusted.Rd @@ -42,22 +42,42 @@ controls. } \section{Using commons with coding agents}{ -A coding agent with an R session, like Posit Assistant, can use a -\code{trusted} object placed in that session directly. Agents without an -interactive REPL, like Claude Code or Codex, can reach one through -\href{https://t-kalinowski.github.io/mcp-console/}{MCP Console}, which gives -them a persistent R session. Build \code{trusted} in the session's startup code -and tell the agent about it in the tool description. - -A configured description replaces the one MCP Console generates, so start -from an export of the resolved configuration, run from the project -directory: - -\if{html}{\out{
}}\preformatted{mcp-console config resolve --full > .agents/console/config.yaml +A coding agent can use a \code{trusted} object in an R session it controls. In +each setup below, code builds \code{trusted} when the session starts and a +sentence tells the agent about it. Here, \code{acme.metrics::trusted()} stands +for whatever code builds the team's \code{trusted} object. + +Posit Assistant runs code in an R session, so build \code{trusted} in the +project's \code{.Rprofile}: + +\if{html}{\out{
}}\preformatted{trusted <- acme.metrics::trusted() +}\if{html}{\out{
}} + +Posit Assistant doesn't always list the session's variables for the model, +so tell it about \code{trusted} in the project's \code{AGENTS.md}: + +\if{html}{\out{
}}\preformatted{The R session has an object called `trusted` that holds this team's vetted +calculations and business context. Before analyzing data, run +`print(trusted)`, and use its calculations where they apply. +}\if{html}{\out{
}} + +Claude Code and Codex have no persistent R session of their own. They can +get one through \href{https://t-kalinowski.github.io/mcp-console/}{MCP Console}. +Register it for the project, from the project directory: + +\if{html}{\out{
}}\preformatted{uvx mcp-console configure-client claude --directory . +uvx mcp-console configure-client codex --directory . +}\if{html}{\out{
}} + +Then configure MCP Console to build \code{trusted} at startup and to mention it +in its tool description. A configured description replaces the one MCP +Console generates, so start from an export of the resolved configuration: + +\if{html}{\out{
}}\preformatted{uvx mcp-console config resolve --full > .agents/console/config.yaml }\if{html}{\out{
}} -Then add startup code that builds \code{trusted}, and add a sentence about it to -the end of the exported description: +In that file, set the startup code, and add the same sentence to the end of +the exported description: \if{html}{\out{
}}\preformatted{startup: language: r @@ -70,13 +90,11 @@ tool: The R session has an object called `trusted` that holds this team's vetted calculations and business context. Before analyzing data, run - `trusted` to print it, and use its calculations where they apply. + `print(trusted)`, and use its calculations where they apply. }\if{html}{\out{
}} -Here, \code{acme.metrics::trusted()} stands for whatever code builds the team's -\code{trusted} object. Some clients, like Claude Code, show the tool description -only once the agent loads the tool, so add the same sentence to -\code{tool.instructions} as well. +Claude Code shows the tool description only once the agent loads the tool, +so add the sentence to the end of \code{tool.instructions} as well. } \examples{ diff --git a/pkg-r/tests/testthat/_snaps/trusted.md b/pkg-r/tests/testthat/_snaps/trusted.md index 9cd12b518..e388863e5 100644 --- a/pkg-r/tests/testthat/_snaps/trusted.md +++ b/pkg-r/tests/testthat/_snaps/trusted.md @@ -53,3 +53,27 @@ i Pass a string like `"project = 'dplyr' AND date >= '2026-06-01'"`, or a list of `c(column, op, value)` triples. i Operators: `=`, `!=`, `<`, `<=`, `>`, and `>=`, joined by `AND`. +# trusted() prints its contents and method signatures + + Code + tr + Output + + Sources: sales_db. Tables: sales. + Measures: + - order_count: Count orders, optionally filtered by region and a revenue ceiling. + Governed metrics: 3. Find them with `$search()`. + Methods: + $search(query) + $measure(name, arguments = list()) + $metrics(metrics, dimensions = NULL, filters = NULL, where = NULL, arguments = list(), source = NULL) + $calculation(name, arguments = list(), source = NULL) + $context(query) + $describe(table, source = NULL) + $connection(source = NULL) + This object provides access to trusted code and context. Before writing + your own analysis code or SQL, look for existing code with `$search()` and run it + with `$measure()`, `$metrics()`, or `$calculation()`. When none fits, + check `$context("your query")` for guidance on the approach. Say in your answer + which numbers came from trusted calculations and which didn't. + diff --git a/pkg-r/tests/testthat/test-trusted.R b/pkg-r/tests/testthat/test-trusted.R index c5cf03b6f..9efd73514 100644 --- a/pkg-r/tests/testthat/test-trusted.R +++ b/pkg-r/tests/testthat/test-trusted.R @@ -60,3 +60,12 @@ test_that("$metrics() accepts `where` as a string or unnamed triples", { tr$metrics("big_revenue", where = list(c("region", "EMEA"))) }) }) + +test_that("trusted() prints its contents and method signatures", { + tr <- trusted( + data_sources = list(sales_db = definitions_source()), + semantic_layer = semantic_layer(count_measure_tool()) + ) + + expect_snapshot(tr) +}) From 1c6328cde247dfeb88e8a08a93276db80a006f48 Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Fri, 23 Oct 2026 10:47:43 -0500 Subject: [PATCH 08/17] rewrite docs --- pkg-r/R/trusted.R | 55 ++++++++++++++++++++++---------------------- pkg-r/man/trusted.Rd | 50 ++++++++++++++++++++-------------------- 2 files changed, 52 insertions(+), 53 deletions(-) diff --git a/pkg-r/R/trusted.R b/pkg-r/R/trusted.R index 8d585f84d..9f9ace36a 100644 --- a/pkg-r/R/trusted.R +++ b/pkg-r/R/trusted.R @@ -1,10 +1,9 @@ #' Trusted calculations and context #' #' `trusted()` gathers a team's data sources, semantic layer, and context layer -#' into one object that code can call directly, without a model. It's the part -#' of a [commons()] agent that doesn't depend on an LLM: pass it to `commons()` -#' to build an agent, or place it in an R session that a coding agent already -#' controls. +#' into one object that code can call directly, without a model. Pass it to +#' `commons()` to build an agent in R, or place it in an R session that a +#' coding agent already controls. #' #' @inheritParams commons #' @@ -18,36 +17,35 @@ #' `"project = 'dplyr' AND date >= '2026-06-01'"` or as a list of #' `c(column, op, value)` triples. #' * `$calculation(name, arguments, source)` runs an exact trusted query. -#' * `$context(query)` searches business context. +#' * `$context(query)` searches trusted context. #' * `$describe(table, source)` describes a table. #' * `$connection(source)` returns a source's DBI connection, for queries #' that no trusted calculation answers. #' -#' @section Using commons with coding agents: -#' A coding agent can use a `trusted` object in an R session it controls. In -#' each setup below, code builds `trusted` when the session starts and a -#' sentence tells the agent about it. Here, `acme.metrics::trusted()` stands -#' for whatever code builds the team's `trusted` object. -#' -#' Posit Assistant runs code in an R session, so build `trusted` in the -#' project's `.Rprofile`: -#' -#' ```r -#' trusted <- acme.metrics::trusted() -#' ``` -#' -#' Posit Assistant doesn't always list the session's variables for the model, -#' so tell it about `trusted` in the project's `AGENTS.md`: -#' +#' @section Using commons with other coding agents: +#' `trusted()` is the entry point to use commons with external coding agents. +#' The approach depends on whether the coding agent already has a persistent +#' R session. For example, Posit Assistant connects to the user's R session. +#' Claude Code and Codex, by contrast, do not have a built-in connection to a +#' persistent R REPL. +#' +#' **For agents that already have an R session, like Posit Assistant**, create +#' a `trusted()` object in the relevant `.Rprofile` and then add a line telling +#' the agent print it out before doing data analysis: +#' #' ```markdown -#' The R session has an object called `trusted` that holds this team's vetted -#' calculations and business context. Before analyzing data, run -#' `print(trusted)`, and use its calculations where they apply. +#' The R session has an object called `trusted` that holds this team's trusted +#' code and context. Before analyzing data, run `print(trusted)`, and use its +#' calculations where they apply in favor of writing analysis code ad-hoc. #' ``` #' -#' Claude Code and Codex have no persistent R session of their own. They can +#' **Claude Code and Codex have no persistent R session of their own.** They can #' get one through [MCP Console](https://t-kalinowski.github.io/mcp-console/). -#' Register it for the project, from the project directory: +#' MCP Console can be configured to run code at startup (which we'll use to +#' build `trusted`), and we'll also edit the console tool's description to tell +#' the model to print out `trusted` before analyzing data. +#' +#' To use MCP console, register it for the project, from the project directory: #' #' ```bash #' uvx mcp-console configure-client claude --directory . @@ -76,8 +74,9 @@ #' # ...the exported description, unchanged... #' #' The R session has an object called `trusted` that holds this team's -#' vetted calculations and business context. Before analyzing data, run -#' `print(trusted)`, and use its calculations where they apply. +#' trsuted code and context. Before analyzing data, run +#' `print(trusted)`, and use its calculations where they apply in favor of +#' writing analysis code ad-hoc. #' ``` #' #' Claude Code shows the tool description only once the agent loads the tool, diff --git a/pkg-r/man/trusted.Rd b/pkg-r/man/trusted.Rd index e70953b11..e856c0727 100644 --- a/pkg-r/man/trusted.Rd +++ b/pkg-r/man/trusted.Rd @@ -27,7 +27,7 @@ columns, either as a string like \code{"project = 'dplyr' AND date >= '2026-06-01'"} or as a list of \code{c(column, op, value)} triples. \item \verb{$calculation(name, arguments, source)} runs an exact trusted query. -\item \verb{$context(query)} searches business context. +\item \verb{$context(query)} searches trusted context. \item \verb{$describe(table, source)} describes a table. \item \verb{$connection(source)} returns a source's DBI connection, for queries that no trusted calculation answers. @@ -35,35 +35,34 @@ that no trusted calculation answers. } \description{ \code{trusted()} gathers a team's data sources, semantic layer, and context layer -into one object that code can call directly, without a model. It's the part -of a \code{\link[=commons]{commons()}} agent that doesn't depend on an LLM: pass it to \code{commons()} -to build an agent, or place it in an R session that a coding agent already -controls. +into one object that code can call directly, without a model. Pass it to +\code{commons()} to build an agent in R, or place it in an R session that a +coding agent already controls. } -\section{Using commons with coding agents}{ +\section{Using commons with other coding agents}{ -A coding agent can use a \code{trusted} object in an R session it controls. In -each setup below, code builds \code{trusted} when the session starts and a -sentence tells the agent about it. Here, \code{acme.metrics::trusted()} stands -for whatever code builds the team's \code{trusted} object. +\code{trusted()} is the entry point to use commons with external coding agents. +The approach depends on whether the coding agent already has a persistent +R session. For example, Posit Assistant connects to the user's R session. +Claude Code and Codex, by contrast, do not have a built-in connection to a +persistent R REPL. -Posit Assistant runs code in an R session, so build \code{trusted} in the -project's \code{.Rprofile}: +\strong{For agents that already have an R session, like Posit Assistant}, create +a \code{trusted()} object in the relevant \code{.Rprofile} and then add a line telling +the agent print it out before doing data analysis: -\if{html}{\out{
}}\preformatted{trusted <- acme.metrics::trusted() +\if{html}{\out{
}}\preformatted{The R session has an object called `trusted` that holds this team's trusted +code and context. Before analyzing data, run `print(trusted)`, and use its +calculations where they apply in favor of writing analysis code ad-hoc. }\if{html}{\out{
}} -Posit Assistant doesn't always list the session's variables for the model, -so tell it about \code{trusted} in the project's \code{AGENTS.md}: - -\if{html}{\out{
}}\preformatted{The R session has an object called `trusted` that holds this team's vetted -calculations and business context. Before analyzing data, run -`print(trusted)`, and use its calculations where they apply. -}\if{html}{\out{
}} - -Claude Code and Codex have no persistent R session of their own. They can +\strong{Claude Code and Codex have no persistent R session of their own.} They can get one through \href{https://t-kalinowski.github.io/mcp-console/}{MCP Console}. -Register it for the project, from the project directory: +MCP Console can be configured to run code at startup (which we'll use to +build \code{trusted}), and we'll also edit the console tool's description to tell +the model to print out \code{trusted} before analyzing data. + +To use MCP console, register it for the project, from the project directory: \if{html}{\out{
}}\preformatted{uvx mcp-console configure-client claude --directory . uvx mcp-console configure-client codex --directory . @@ -89,8 +88,9 @@ tool: # ...the exported description, unchanged... The R session has an object called `trusted` that holds this team's - vetted calculations and business context. Before analyzing data, run - `print(trusted)`, and use its calculations where they apply. + trsuted code and context. Before analyzing data, run + `print(trusted)`, and use its calculations where they apply in favor of + writing analysis code ad-hoc. }\if{html}{\out{
}} Claude Code shows the tool description only once the agent loads the tool, From ef7e7c791aa044c733eb24be6460dfd3b077baa8 Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Fri, 23 Oct 2026 11:00:24 -0500 Subject: [PATCH 09/17] Only list trusted's calculation methods that have something to run --- pkg-r/R/trusted.R | 42 +++++++++++++++++++++----- pkg-r/tests/testthat/_snaps/trusted.md | 26 +++++++++++++--- pkg-r/tests/testthat/test-trusted.R | 4 +++ 3 files changed, 59 insertions(+), 13 deletions(-) diff --git a/pkg-r/R/trusted.R b/pkg-r/R/trusted.R index 9f9ace36a..1e6f54da0 100644 --- a/pkg-r/R/trusted.R +++ b/pkg-r/R/trusted.R @@ -222,6 +222,14 @@ trusted_overview <- function(state) { function(td) sprintf(" - %s: %s", tool_name(td), tool_description(td)), character(1) ) + runners <- c("measure", "metrics", "calculation")[ + c(length(measures) > 0, n_metrics > 0, length(state$calculations) > 0) + ] + run_with <- cli::ansi_collapse( + sprintf("`$%s()`", runners), + sep2 = " or ", + last = ", or " + ) c( "", sprintf( @@ -241,18 +249,36 @@ trusted_overview <- function(state) { }, if (state$has_context_layer) "Business context is available.", "Methods:", - trusted_method_signatures(), - "This object provides access to trusted code and context. Before writing", - "your own analysis code or SQL, look for existing code with `$search()` and run it", - "with `$measure()`, `$metrics()`, or `$calculation()`. When none fits,", - "check `$context(\"your query\")` for guidance on the approach. Say in your answer", - "which numbers came from trusted calculations and which didn't." + trusted_method_signatures( + hide = setdiff(c("measure", "metrics", "calculation"), runners) + ), + strwrap(paste( + "This object provides access to trusted code and context.", + if (length(runners)) { + paste( + "Before writing your own analysis code or SQL, look for existing", + "code with `$search()` and run it with", paste0(run_with, "."), + "When none fits, check `$context(\"your query\")` for guidance on", + "the approach." + ) + } else { + paste( + "Before writing your own analysis code or SQL, check", + "`$context(\"your query\")` for guidance on the approach." + ) + }, + "Say in your answer which numbers came from trusted calculations and", + "which didn't." + )) ) } -trusted_method_signatures <- function() { +# Methods that run a kind of calculation the object has none of are hidden. +trusted_method_signatures <- function(hide = character()) { methods <- Trusted$public_methods - methods <- methods[setdiff(names(methods), c("initialize", "print", "clone"))] + methods <- methods[ + setdiff(names(methods), c("initialize", "print", "clone", hide)) + ] vapply( names(methods), function(name) { diff --git a/pkg-r/tests/testthat/_snaps/trusted.md b/pkg-r/tests/testthat/_snaps/trusted.md index e388863e5..f094aebbd 100644 --- a/pkg-r/tests/testthat/_snaps/trusted.md +++ b/pkg-r/tests/testthat/_snaps/trusted.md @@ -67,13 +67,29 @@ $search(query) $measure(name, arguments = list()) $metrics(metrics, dimensions = NULL, filters = NULL, where = NULL, arguments = list(), source = NULL) - $calculation(name, arguments = list(), source = NULL) $context(query) $describe(table, source = NULL) $connection(source = NULL) This object provides access to trusted code and context. Before writing - your own analysis code or SQL, look for existing code with `$search()` and run it - with `$measure()`, `$metrics()`, or `$calculation()`. When none fits, - check `$context("your query")` for guidance on the approach. Say in your answer - which numbers came from trusted calculations and which didn't. + your own analysis code or SQL, look for existing code with `$search()` + and run it with `$measure()` or `$metrics()`. When none fits, check + `$context("your query")` for guidance on the approach. Say in your + answer which numbers came from trusted calculations and which didn't. + +# trusted() lists only the calculation methods it can run + + Code + trusted(list(sales_db = test_source())) + Output + + Sources: sales_db. Tables: sales. + Methods: + $search(query) + $context(query) + $describe(table, source = NULL) + $connection(source = NULL) + This object provides access to trusted code and context. Before writing + your own analysis code or SQL, check `$context("your query")` for + guidance on the approach. Say in your answer which numbers came from + trusted calculations and which didn't. diff --git a/pkg-r/tests/testthat/test-trusted.R b/pkg-r/tests/testthat/test-trusted.R index 9efd73514..afb1a59ea 100644 --- a/pkg-r/tests/testthat/test-trusted.R +++ b/pkg-r/tests/testthat/test-trusted.R @@ -69,3 +69,7 @@ test_that("trusted() prints its contents and method signatures", { expect_snapshot(tr) }) + +test_that("trusted() lists only the calculation methods it can run", { + expect_snapshot(trusted(list(sales_db = test_source()))) +}) From b61eee2159986494012435305f4da8f56c37f43e Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Mon, 26 Oct 2026 08:12:16 -0500 Subject: [PATCH 10/17] Rename `commons()` to `agent()` and `trusted()` to `commons()` `commons()` now returns the commons: data sources, semantic layer, and context layer, callable without a model. Its arguments must be named. `agent(client, commons = )` builds the agent. Calls that pass a client to `commons()` still return an agent, with a deprecation warning. --- pkg-r/DESCRIPTION | 1 + pkg-r/NAMESPACE | 4 +- pkg-r/NEWS.md | 4 +- pkg-r/R/agent.R | 576 ++++++++++++ pkg-r/R/chat.R | 12 +- pkg-r/R/commons.R | 880 ++++++++---------- pkg-r/R/context-layer.R | 4 +- pkg-r/R/data-source.R | 4 +- pkg-r/R/measures.R | 10 +- pkg-r/R/pool.R | 2 +- pkg-r/R/trajectory-read.R | 8 +- pkg-r/R/trusted.R | 377 -------- pkg-r/_pkgdown.yml | 2 +- pkg-r/inst/app.R | 18 +- pkg-r/inst/demo.R | 14 +- pkg-r/inst/examples/plot-app/app.R | 8 +- pkg-r/inst/examples/snowflake/app.R | 4 +- pkg-r/inst/skills/commons/SKILL.md | 4 +- .../references/extracting-from-artifacts.md | 2 +- .../references/iterating-from-trajectories.md | 2 +- .../skills/commons/references/onboarding.md | 6 +- pkg-r/man/agent.Rd | 212 +++++ pkg-r/man/commons.Rd | 261 ++---- pkg-r/man/commons_app.Rd | 6 +- pkg-r/man/commons_server.Rd | 6 +- pkg-r/man/context_layer.Rd | 4 +- pkg-r/man/data_source.Rd | 4 +- pkg-r/man/semantic_layer.Rd | 10 +- pkg-r/man/trajectory_read.Rd | 8 +- pkg-r/man/trusted.Rd | 114 --- pkg-r/tests/testthat/_snaps/agent.md | 25 + pkg-r/tests/testthat/_snaps/commons.md | 157 +++- pkg-r/tests/testthat/_snaps/trusted-only.md | 2 +- pkg-r/tests/testthat/_snaps/trusted.md | 95 -- .../tests/testthat/apps/citation-stream/app.R | 2 +- pkg-r/tests/testthat/helper-commons.R | 10 +- pkg-r/tests/testthat/test-agent.R | 811 ++++++++++++++++ pkg-r/tests/testthat/test-commons.R | 875 ++--------------- pkg-r/tests/testthat/test-trusted.R | 75 -- pkg-r/vignettes/commons.Rmd | 14 +- pkg-r/vignettes/governance.Rmd | 8 +- 41 files changed, 2407 insertions(+), 2234 deletions(-) create mode 100644 pkg-r/R/agent.R delete mode 100644 pkg-r/R/trusted.R create mode 100644 pkg-r/man/agent.Rd delete mode 100644 pkg-r/man/trusted.Rd create mode 100644 pkg-r/tests/testthat/_snaps/agent.md delete mode 100644 pkg-r/tests/testthat/_snaps/trusted.md create mode 100644 pkg-r/tests/testthat/test-agent.R delete mode 100644 pkg-r/tests/testthat/test-trusted.R diff --git a/pkg-r/DESCRIPTION b/pkg-r/DESCRIPTION index 2870d240d..0d3a9b65e 100644 --- a/pkg-r/DESCRIPTION +++ b/pkg-r/DESCRIPTION @@ -34,6 +34,7 @@ Imports: jsonlite, knitr, later, + lifecycle, processx, promises (>= 1.5.0), R6, diff --git a/pkg-r/NAMESPACE b/pkg-r/NAMESPACE index 523a3110f..15e9966f6 100644 --- a/pkg-r/NAMESPACE +++ b/pkg-r/NAMESPACE @@ -1,6 +1,7 @@ # Generated by roxygen2: do not edit by hand -S3method(print,commons_trusted_text) +S3method(print,commons_text) +export(agent) export(commons) export(commons_app) export(commons_server) @@ -11,7 +12,6 @@ export(measure) export(semantic_layer) export(trajectory_read) export(trajectory_review) -export(trusted) importFrom(R6,R6Class) importFrom(coro, async_generator, diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md index 079dfcda7..70a0a53fc 100644 --- a/pkg-r/NEWS.md +++ b/pkg-r/NEWS.md @@ -1,6 +1,8 @@ # commons (development version) -* `commons()` gains a `mode` argument. With `mode = "trusted only"`, the agent answers only with trusted calculations. It never writes its own SQL or R, and it tells the user when no trusted calculation answers a question. These answers carry no provenance markers. +* `commons()` now gathers data sources, semantic layer, and context layer into "the commons", an object that code can call directly without a model, such as from a coding agent's R session. Its arguments must be named. The new `agent()` builds an agent from a client and the commons, replacing the old role of `commons()`. Calls that pass a client to `commons()` still work and return an agent, with a deprecation warning. + +* `agent()` gains a `mode` argument. With `mode = "trusted only"`, the agent answers only with trusted calculations. It never writes its own SQL or R, and it tells the user when no trusted calculation answers a question. These answers carry no provenance markers. * `describe_table` reports complete table schemas and documentation without a sampled-row summary. DuckDB descriptions use database-native types. Snowflake and Databricks continue to use native metadata, while other DBI connections infer R column classes from five sampled rows, or zero rows in trusted-only mode (#319). diff --git a/pkg-r/R/agent.R b/pkg-r/R/agent.R new file mode 100644 index 000000000..04d686fc5 --- /dev/null +++ b/pkg-r/R/agent.R @@ -0,0 +1,576 @@ +#' Create a commons agent +#' +#' `agent()` creates an [ellmer::Chat] subclass with tools and prompting that +#' allow the agent to navigate the data sources, semantic layer, and context +#' layer gathered by [commons()]. Depending on the agent's choice of tools, +#' responses can be deterministically classified as based on a trusted +#' calculation, cited, or untrusted. +#' +#' The provider and model come from `client`; commons sets its own system prompt +#' and tools. Use `agent$chat()` to ask questions, [commons_theme()] and +#' [commons_server()] to embed the agent in Shiny, and [vitals::generate()] +#' to use the agent as a vitals solver. +#' +#' @param client An [ellmer::Chat] giving the provider and model to use, e.g. +#' [ellmer::chat_anthropic()]. For best results, enable thinking when +#' supported by the selected provider and model. A system prompt already set +#' on the client is ignored, with a warning; use `instructions` to add to +#' commons' prompt. +#' @param commons A [commons()] object holding the agent's data sources, +#' semantic layer, and context layer. +#' @param ... These dots are for future extensions and must be empty. +#' @param instructions Optional instructions placed under an +#' `## Additional instructions` heading at the end of commons' built-in +#' system prompt, as a single string or the path to a text or Markdown file. +#' +#' ```r +#' agent( +#' # ... +#' instructions = "Use the organization's fiscal-year conventions." +#' ) +#' ``` +#' @param mode Which analysis paths the agent may take. One of: +#' +#' * `"normal"` (the default): the agent prefers trusted calculations and, +#' when none answers the question, falls back to writing its own SQL and R +#' code. +#' * `"trusted only"`: the agent answers only with trusted calculations: +#' measures, governed metrics, and exact trusted queries. When none +#' answers the question, it says so rather than writing its own code. The +#' agent can still search context and inspect table schemas. Because +#' every answer comes from a trusted calculation, answers carry no +#' provenance markers, and the agent needs no sandboxed R session. The +#' agent must have at least one trusted calculation. +#' @param network Whether the agent's R session has network access. One +#' of +#' `"none"` (the default) or `"full"`. Must be `"none"` when +#' `mode = "trusted only"`, since that mode has no R session. The session uses OS sandboxing on +#' Linux and macOS. On unsupported hosts, local development can opt in to +#' best-effort R guardrails with +#' `options(commons.allow_unsafe_fallback = TRUE)`. These guardrails +#' are not a security boundary. +#' @param log Whether to request conversation trajectory capture with +#' OpenTelemetry (default `FALSE`). When `TRUE`, commons checks the tracing +#' setup and warns with setup steps when it is incomplete. This feature +#' requires Connect >= 2026.09.0. +#' +#' When deploying to Posit Connect, include +#' `"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"` in the `envVars` +#' argument to [rsconnect::deployApp()] so \pkg{ellmer} includes message +#' content. A server administrator must also set `OpenTelemetry.Enabled = true` +#' and `OpenTelemetry.AllowContentInstrumentation = true`. +#' +#' Once the agent is deployed and serving traffic, you can read conversation +#' histories back into R with [trajectory_read()]. +#' @param share_with An optional character vector of Connect usernames granted +#' access to this content's trajectories when running on Posit Connect. +#' Reading traces requires editor-level access, so named users are added as +#' collaborators on the content. Note that users whose Connect *account* +#' role is viewer cannot read traces even when named here; trace readers +#' need at least a publisher account. +#' +#' @section Cache pre-warming: +#' A commons agent builds its context search index and downloads uncached pins +#' the first time it needs them. [commons_server()] and [commons_app()] call the +#' agent's `prewarm()` method automatically during post-startup idle time. +#' +#' To warm the caches before deployment, call `agent$prewarm()` in a +#' pre-deploy script. The context index is cached on disk once per version of +#' the context documents; pin downloads populate the local pins cache. +#' +#' To ship a pre-built context index with an app, configure a directory inside +#' the app in both the pre-deploy script and the deployed app, then prewarm the +#' agent before deploying: +#' +#' ```r +#' options(commons.context_cache = "commons-cache") +#' agent <- agent( +#' ellmer::chat_anthropic(), +#' commons = commons(data_sources = data_source(sales = sales)) +#' ) +#' agent$prewarm() +#' ``` +#' +#' Do not use `app_cache/` for this workflow because rsconnect excludes it from +#' deployed bundles. Without explicit configuration, commons uses Connect's +#' persistent content data directory when available, an `app_cache/` directory +#' beside hosted apps, or the per-user cache directory. Set the cache directory +#' with `options(commons.context_cache = "path/to/dir")` or the +#' `COMMONS_CONTEXT_CACHE` environment variable. Set the option to `FALSE` to +#' disable persistence. The cache is capped at 256 MB with least-recently-used +#' eviction; change the cap with `options(commons.context_cache_max_size)`. +#' +#' @section Agent tools: +#' Depending on its semantic layer, context layer, data sources, and `mode`, a +#' commons agent receives some combination of these tools: +#' +#' * `search_pool` searches trusted calculations and semantic models. +#' * `search_catalog` searches a warehouse catalog. +#' * `call_measure` invokes an R measure. +#' * `call_metrics` invokes governed or warehouse-native metrics. +#' * `call_calculation` invokes an exact trusted query. +#' * `search_context` retrieves relevant business context. +#' * `load_skill` loads instructions for a task, such as explaining how commons +#' determines and displays answer trust. +#' * `describe_table` inspects a table or semantic model. +#' * `run_sql` executes a read-only SQL query. +#' * `run_r` executes R code to analyze results and render plots in the agent's +#' R session. +#' +#' With `mode = "trusted only"`, the agent never receives `run_sql` or `run_r`. +#' +#' These model-facing tools should be considered private. Their constructors +#' are intentionally not exported, and their names, arguments, availability, +#' and behavior may change without notice. Application code should configure +#' an agent through `agent()`, `commons()`, and the layer constructors rather +#' than depend on individual tools. +#' +#' @return An [ellmer::Chat] subclass. +#' +#' @examples +#' \dontrun{ +#' # A measure over local data computes directly in R. +#' sem <- semantic_layer( +#' measure( +#' "order_count", +#' "Count of orders.", +#' function() nrow(my_sales), +#' arguments = list() +#' ) +#' ) +#' agent <- agent( +#' ellmer::chat_anthropic(), +#' commons = commons( +#' data_sources = data_source(sales = my_sales), +#' semantic_layer = sem +#' ) +#' ) +#' agent$chat("How many orders are there?") +#' +#' # A measure takes a connection as an argument named after a data source. +#' # `warehouse` isn't in `arguments`, so the model never sees it; commons +#' # supplies it when the measure runs. Bind model-supplied arguments through +#' # DBI so they're quoted safely. +#' con <- DBI::dbConnect(duckdb::duckdb()) +#' sem <- semantic_layer( +#' measure( +#' "revenue_by_region", +#' "Total revenue for a region.", +#' function(region, warehouse) { +#' DBI::dbGetQuery( +#' warehouse, +#' "SELECT sum(revenue) AS revenue FROM sales WHERE region = ?", +#' params = list(region) +#' ) +#' }, +#' arguments = list(region = ellmer::type_string("Sales region.")) +#' ) +#' ) +#' agent <- agent( +#' ellmer::chat_anthropic(), +#' commons = commons( +#' data_sources = list(warehouse = data_source(con)), +#' semantic_layer = sem +#' ) +#' ) +#' +#' # Objects that aren't data sources (a pins board, an API client) come from +#' # argument defaults in the measure, e.g. `board = pins::board_connect()`. +#' # See ?semantic_layer. +#' } +#' +#' @export +agent <- function( + client, + commons, + ..., + instructions = NULL, + mode = c("normal", "trusted only"), + network = c("none", "full"), + log = FALSE, + share_with = NULL +) { + rlang::check_dots_empty() + if (!inherits(client, "Chat")) { + cli::cli_abort( + "{.arg client} must be an {.cls ellmer::Chat}, e.g. from {.fn ellmer::chat_anthropic}." + ) + } + if (!is.null(client$get_system_prompt())) { + cli::cli_warn( + c( + "The system prompt set on {.arg client} is ignored; commons builds + its own.", + i = "Use {.arg instructions} to add to commons' prompt." + ) + ) + } + if (!inherits(commons, "commons")) { + cli::cli_abort(c( + "{.arg commons} must be a {.fn commons} object, not {.obj_type_friendly {commons}}.", + i = "Gather data sources and layers with + {.code commons(data_sources = ...)}." + )) + } + mode <- rlang::arg_match(mode) + network <- rlang::arg_match(network) + trusted_only <- identical(mode, "trusted only") + if (trusted_only && identical(network, "full")) { + cli::cli_abort( + "{.code network = \"full\"} can't be combined with + {.code mode = \"trusted only\"}, which has no R session." + ) + } + # Without run_r there is nothing to sandbox, so the host needs no support. + protection <- if (trusted_only) NULL else run_r_protection_mode() + check_instructions(instructions) + rlang::check_bool(log) + check_share_with(share_with) + + Commons$new( + client = client, + commons = commons, + network = network, + protection = protection, + instructions = instructions, + mode = mode, + log = log, + share_with = share_with + ) +} + +Commons <- R6::R6Class( + "Commons", + inherit = ellmer::Chat, + public = list( + initialize = function( + client, + commons, + ..., + instructions = NULL, + mode = c("normal", "trusted only"), + network = c("none", "full"), + protection = NULL, + log = FALSE, + share_with = NULL + ) { + rlang::check_dots_empty() + super$initialize( + provider = client$get_provider(), + model = client$get_model_object(), + echo = "none" + ) + mode <- rlang::arg_match(mode) + network <- rlang::arg_match(network) + private$trusted_only <- identical(mode, "trusted only") + + state <- commons_state_of(commons) + sources <- state$sources + private$commons_obj <- commons + private$sources <- sources + private$context_layer <- state$context_layer + private$first_touch <- new.env(parent = emptyenv()) + private$definitions <- state$definitions + private$semantic_models <- state$semantic_models + private$calculations <- state$calculations + private$registry <- state$registry + private$fn_sources <- state$fn_sources + private$measure_provenance <- state$measure_provenance + private$measure_display <- state$measure_display + private$injections <- state$injections + private$tracing <- new_trajectory_tracing(log, share_with) + + local_commons_span( + "commons_agent_create", + attributes = list( + "commons.agent.n_data_sources" = length(sources), + "commons.agent.has_context_layer" = state$has_context_layer, + "commons.agent.n_measures" = length(state$registry), + "commons.agent.n_definitions" = nrow(private$definitions$defs), + "commons.agent.n_semantic_members" = nrow( + private$semantic_models$members + ), + "commons.agent.n_calculations" = length(private$calculations) + ) + ) + + private$corpus <- build_citation_corpus( + private$context_layer, + private$registry, + sources + ) + private$restore_reminder_pending <- FALSE + # Handles, the worker, and citation requests all serve the fallback + # path, which a trusted-only agent doesn't have. + if (!private$trusted_only) { + private$handles <- new_handle_store() + private$worker <- new_r_worker( + network, + protection %||% run_r_protection_mode() + ) + private$citation_request <- new.env(parent = emptyenv()) + private$citation_request$reminder <- citation_reminder_text() + } + + commons_tools <- build_commons_tools(self, private) + if (private$trusted_only) { + check_trusted_tools(commons_tools) + } + self$register_tools(commons_tools) + self$set_system_prompt( + commons_system_prompt( + private$sources, + definitions = private$definitions, + instructions = instructions, + tools = commons_tools, + model = self$get_model(), + trusted_only = private$trusted_only + ) + ) + }, + + add_turn = function(user, assistant, log_tokens = TRUE) { + if (turn_has_user_message(user) && !is.null(private$citation_request)) { + private$citation_request$requested <- FALSE + } + super$add_turn(user, assistant, log_tokens = log_tokens) + }, + + set_turns = function(value) { + private$restore_reminder_pending <- FALSE + super$set_turns(value) + }, + + chat = function(..., echo = NULL) { + if (private$tracing) { + local_conversation_turn_span() + } + restore_reminder_pending <- private$restore_reminder_pending + inputs <- private$prepare_turn_inputs(rlang::list2(...)) + result <- withVisible(do.call(super$chat, c(inputs, list(echo = echo)))) + private$consume_restore_reminder(restore_reminder_pending) + if (result$visible) result$value else invisible(result$value) + }, + + stream_async = function( + ..., + tool_mode = c("concurrent", "sequential"), + stream = c("text", "content"), + controller = NULL + ) { + from_index <- length(self$get_turns()) + 1L + stream <- rlang::arg_match(stream) + restore_reminder_pending <- private$restore_reminder_pending + inputs <- private$prepare_turn_inputs(rlang::list2(...)) + raw_stream <- do.call( + super$stream_async, + c( + inputs, + list( + tool_mode = tool_mode, + stream = stream, + controller = controller + ) + ) + ) + + tracing <- private$tracing + trusted_only <- private$trusted_only + # A trusted-only agent shows no provenance markers. An empty corpus still + # strips citation markup but rejects every citation, so none renders. + corpus <- if (trusted_only) list() else private$corpus + as_content <- identical(stream, "content") + + # Always project citations so reserved model markup cannot reach the browser. + coro::async_generator(function() { + span <- NULL + if (tracing) { + span <- local_conversation_turn_span() + } + scanner <- citation_scanner(corpus) + + for (chunk in coro::await_each(raw_stream)) { + if (is.character(chunk)) { + out <- scanner$feed(chunk) + if (nzchar(out)) yield(out) + } else if (S7::S7_inherits(chunk, ellmer::ContentText)) { + out <- scanner$feed(chunk@text) + if (nzchar(out)) yield(ellmer::ContentText(out)) + } else { + yield(chunk) + } + } + + private$consume_restore_reminder(restore_reminder_pending) + + tail <- scanner$finish() + if (nzchar(tail)) { + yield(if (as_content) ellmer::ContentText(tail) else tail) + } + + decisions <- scanner$decisions() + verified <- any(vapply( + decisions, + function(d) identical(d$status, "accepted"), + logical(1) + )) + turns <- self$get_turns() + tag <- derive_provenance_tag( + collect_appended_tags(turns, from_index), + verified + ) + + if (tracing) { + # An absent provenance tag must not suppress the citation audit record. + if (!is.na(tag)) { + tryCatch( + commons_span_set_attribute(span, "commons.provenance.tag", tag), + error = function(err) NULL + ) + } + tryCatch( + commons_span_set_attribute( + span, + "commons.citation.candidates", + jsonlite::toJSON(decisions, auto_unbox = TRUE) + ), + error = function(err) NULL + ) + tryCatch( + record_provenance_span(tag, decisions), + error = function(err) NULL + ) + } + + # A trusted-only agent shows no provenance marker. (coro generators + # can't assign the result of an `if` expression.) + display_tag <- tag + if (trusted_only) { + display_tag <- NA_character_ + } + aside <- provenance_aside(display_tag) + if (nzchar(aside)) { + yield(if (as_content) ellmer::ContentText(aside) else aside) + } + coro::exhausted() + })() + }, + + citation_corpus = function() { + private$corpus + }, + + queue_restore_reminder = function() { + private$restore_reminder_pending <- TRUE + invisible(self) + }, + + prewarm = function() { + # A direct call is typically warming caches ahead of deployment, so + # failures propagate: a cold cache should fail the deploy. + # prewarm_on_idle() downgrades them to warnings. + private$prewarm_context() + private$prewarm_sources() + invisible(self) + } + ), + active = list( + commons = function() private$commons_obj + ), + private = list( + prewarm_context = function() { + layer <- private$context_layer + layer_state <- if (is.null(layer)) NULL else context_layer_state(layer) + if (!is.null(layer_state) && length(layer_state$docs) > 0) { + local_commons_span( + "commons_context_prewarm", + attributes = list( + "commons.context.n_docs" = length(layer_state$docs), + # tryCatch: telemetry must not abort prewarming (resolving the + # cache dir can fail or warn on an unwritable root). + "commons.context.cache_hit" = + !is.null(layer_state$store) || + isTRUE(tryCatch( + context_cache_enabled() && + file.exists(context_store_path(layer_state$docs)), + error = function(err) FALSE + )) + ) + ) + context_store(layer) + } + invisible(self) + }, + + prewarm_sources = function() { + for (source in private$sources) { + source_prewarm(source) + } + invisible(self) + }, + + commons_obj = NULL, + sources = NULL, + trusted_only = FALSE, + context_layer = NULL, + registry = NULL, + definitions = NULL, + semantic_models = NULL, + calculations = NULL, + fn_sources = NULL, + measure_provenance = NULL, + measure_display = NULL, + injections = NULL, + tracing = FALSE, + first_touch = NULL, + handles = NULL, + worker = NULL, + corpus = NULL, + citation_request = NULL, + restore_reminder_pending = FALSE, + + prepare_turn_inputs = function(inputs) { + inputs <- append_turn_reminder(inputs, self$get_model()) + # The restore reminder is about run_r state, which this mode never has. + if (private$restore_reminder_pending && !private$trusted_only) { + inputs <- append_restored_conversation_reminder(inputs) + } + inputs + }, + + consume_restore_reminder = function(was_pending) { + if (was_pending) { + private$restore_reminder_pending <- FALSE + } + invisible(NULL) + } + ) +) + +# A trusted-only agent answers only with trusted calculations, so one that +# has none could answer nothing. +check_trusted_tools <- function(tools) { + names <- vapply(tools, tool_name, character(1)) + if (any(c("call_measure", "call_metrics", "call_calculation") %in% names)) { + return(invisible(NULL)) + } + cli::cli_abort( + c( + "{.code mode = \"trusted only\"} needs at least one trusted + calculation.", + i = "Add measures to {.arg semantic_layer}, governed metric + definitions to a data dictionary, or a warehouse semantic model." + ), + call = NULL + ) +} + +turn_has_user_message <- function(turn) { + any(!vapply(turn@contents, is_tool_result_content, logical(1))) +} + +# Measures can take a named source's connection as an argument. +measure_injectables <- function(sources) { + named <- sources[rlang::have_name(sources)] + lapply(named, function(source) data_source_state(source)$con) +} diff --git a/pkg-r/R/chat.R b/pkg-r/R/chat.R index c4afbde8a..aa197398b 100644 --- a/pkg-r/R/chat.R +++ b/pkg-r/R/chat.R @@ -5,7 +5,7 @@ #' assemble the UI and server yourself with [commons_theme()] and #' [commons_server()]. #' -#' @param client A [commons()] agent. +#' @param client An [agent()]. #' @param ... Extra arguments passed to [shiny::shinyApp()]. #' #' @return A [shiny::shinyApp()] object. @@ -19,9 +19,9 @@ #' #' @examples #' \dontrun{ -#' agent <- commons( +#' agent <- agent( #' ellmer::chat_anthropic(), -#' data_sources = data_source(sales = sales) +#' commons = commons(data_sources = data_source(sales = sales)) #' ) #' commons_app(agent) #' } @@ -69,7 +69,7 @@ commons_app <- function(client, ...) { #' ordinary [bslib::bs_theme()] (via [shinychat::page_chat_theme()]), so it #' works anywhere a bslib theme does. #' -#' @param client A [commons()] agent. In a deployed app, create the agent +#' @param client An [agent()]. In a deployed app, create the agent #' inside the server function and pass it to `commons_server()` so each #' Shiny session gets its own agent state. #' @param id The ID of the chat element; must match the `id` of the @@ -91,9 +91,9 @@ commons_app <- function(client, ...) { #' #' server <- function(input, output, session) { #' # One agent per session, so each user gets their own agent state -#' agent <- commons( +#' agent <- agent( #' ellmer::chat_anthropic(), -#' data_sources = data_source(sales = sales) +#' commons = commons(data_sources = data_source(sales = sales)) #' ) #' commons_server("chat", agent) #' } diff --git a/pkg-r/R/commons.R b/pkg-r/R/commons.R index 28b939285..e27d82434 100644 --- a/pkg-r/R/commons.R +++ b/pkg-r/R/commons.R @@ -1,186 +1,150 @@ -#' Create a commons agent +#' Gather trusted calculations and context #' -#' `commons()` creates an [ellmer::Chat] subclass with tools and prompting that -#' allow the agent to navigate its data sources, semantic layer, and context -#' layer. Depending on the agent's choice of tools, responses can be -#' deterministically classified as based on a trusted calculation, cited, or -#' untrusted. +#' `commons()` gathers a team's data sources, semantic layer, and context layer +#' into one object, "the commons", that code can call directly without a model. +#' Pass it to [agent()] to build an agent in R, or place it in an R session +#' that a coding agent already controls. #' -#' The provider and model come from `client`; commons sets its own system prompt -#' and tools. Use `agent$chat()` to ask questions, [commons_theme()] and -#' [commons_server()] to embed the agent in Shiny, and [vitals::generate()] -#' to use the agent as a vitals solver. +#' All arguments must be named. #' -#' @param client An [ellmer::Chat] giving the provider and model to use, e.g. -#' [ellmer::chat_anthropic()]. For best results, enable thinking when -#' supported by the selected provider and model. A system prompt already set -#' on the client is ignored, with a warning; use `instructions` to add to -#' commons' prompt. +#' @param ... These dots are for future extensions and must be empty. #' @param data_sources A [data_source()], or a named list of them. Measures #' can take a source's connection as an argument named after the source; see -#' [semantic_layer()]. In `commons()`, this can also be a [trusted()] object, -#' in which case `semantic_layer` and `context_layer` must be `NULL`. +#' [semantic_layer()]. #' @param semantic_layer An optional [semantic_layer()]. #' @param context_layer An optional [context_layer()]. -#' @param ... These dots are for future extensions and must be empty. -#' @param instructions Optional instructions placed under an -#' `## Additional instructions` heading at the end of commons' built-in -#' system prompt, as a single string or the path to a text or Markdown file. #' -#' ```r -#' commons( -#' # ... -#' instructions = "Use the organization's fiscal-year conventions." -#' ) -#' ``` -#' @param mode Which analysis paths the agent may take. One of: +#' @return A `commons` object with methods: #' -#' * `"normal"` (the default): the agent prefers trusted calculations and, -#' when none answers the question, falls back to writing its own SQL and R -#' code. -#' * `"trusted only"`: the agent answers only with trusted calculations: -#' measures, governed metrics, and exact trusted queries. When none -#' answers the question, it says so rather than writing its own code. The -#' agent can still search context and inspect table schemas. Because -#' every answer comes from a trusted calculation, answers carry no -#' provenance markers, and the agent needs no sandboxed R session. The -#' agent must have at least one trusted calculation. -#' @param network Whether the agent's R session has network access. One -#' of -#' `"none"` (the default) or `"full"`. Must be `"none"` when -#' `mode = "trusted only"`, since that mode has no R session. The session uses OS sandboxing on -#' Linux and macOS. On unsupported hosts, local development can opt in to -#' best-effort R guardrails with -#' `options(commons.allow_unsafe_fallback = TRUE)`. These guardrails -#' are not a security boundary. -#' @param log Whether to request conversation trajectory capture with -#' OpenTelemetry (default `FALSE`). When `TRUE`, commons checks the tracing -#' setup and warns with setup steps when it is incomplete. This feature -#' requires Connect >= 2026.09.0. +#' * `$search(query)` searches trusted calculations. +#' * `$measure(name, arguments)` runs a measure. +#' * `$metrics(metrics, dimensions, filters, where, arguments, source)` +#' computes governed metrics. `where` takes simple conditions on documented +#' columns, either as a string like +#' `"project = 'dplyr' AND date >= '2026-06-01'"` or as a list of +#' `c(column, op, value)` triples. +#' * `$calculation(name, arguments, source)` runs an exact trusted query. +#' * `$context(query)` searches trusted context. +#' * `$describe(table, source)` describes a table. +#' * `$connection(source)` returns a source's DBI connection, for queries +#' that no trusted calculation answers. #' -#' When deploying to Posit Connect, include -#' `"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"` in the `envVars` -#' argument to [rsconnect::deployApp()] so \pkg{ellmer} includes message -#' content. A server administrator must also set `OpenTelemetry.Enabled = true` -#' and `OpenTelemetry.AllowContentInstrumentation = true`. +#' @section Using commons with other coding agents: +#' `commons()` is the entry point to use commons with external coding agents. +#' The approach depends on whether the coding agent already has a persistent +#' R session. For example, Posit Assistant connects to the user's R session. +#' Claude Code and Codex, by contrast, do not have a built-in connection to a +#' persistent R REPL. #' -#' Once the agent is deployed and serving traffic, you can read conversation -#' histories back into R with [trajectory_read()]. -#' @param share_with An optional character vector of Connect usernames granted -#' access to this content's trajectories when running on Posit Connect. -#' Reading traces requires editor-level access, so named users are added as -#' collaborators on the content. Note that users whose Connect *account* -#' role is viewer cannot read traces even when named here; trace readers -#' need at least a publisher account. +#' **For agents that already have an R session, like Posit Assistant**, create +#' a `commons()` object in the relevant `.Rprofile` and then add a line telling +#' the agent to print it out before doing data analysis: #' -#' @section Cache pre-warming: -#' A commons agent builds its context search index and downloads uncached pins -#' the first time it needs them. [commons_server()] and [commons_app()] call the -#' agent's `prewarm()` method automatically during post-startup idle time. +#' ```markdown +#' The R session has an object called `commons` that holds this team's trusted +#' code and context. Before analyzing data, run `print(commons)`, and use its +#' calculations where they apply in favor of writing analysis code ad-hoc. +#' ``` #' -#' To warm the caches before deployment, call `agent$prewarm()` in a -#' pre-deploy script. The context index is cached on disk once per version of -#' the context documents; pin downloads populate the local pins cache. +#' **Claude Code and Codex have no persistent R session of their own.** They can +#' get one through [MCP Console](https://t-kalinowski.github.io/mcp-console/). +#' MCP Console can be configured to run code at startup (which we'll use to +#' build `commons`), and we'll also edit the console tool's description to tell +#' the model to print out `commons` before analyzing data. #' -#' To ship a pre-built context index with an app, configure a directory inside -#' the app in both the pre-deploy script and the deployed app, then prewarm the -#' agent before deploying: +#' To use MCP console, register it for the project, from the project directory: #' -#' ```r -#' options(commons.context_cache = "commons-cache") -#' agent <- commons( -#' ellmer::chat_anthropic(), -#' data_sources = data_source(sales = sales) -#' ) -#' agent$prewarm() +#' ```bash +#' uvx mcp-console configure-client claude --directory . +#' uvx mcp-console configure-client codex --directory . #' ``` #' -#' Do not use `app_cache/` for this workflow because rsconnect excludes it from -#' deployed bundles. Without explicit configuration, commons uses Connect's -#' persistent content data directory when available, an `app_cache/` directory -#' beside hosted apps, or the per-user cache directory. Set the cache directory -#' with `options(commons.context_cache = "path/to/dir")` or the -#' `COMMONS_CONTEXT_CACHE` environment variable. Set the option to `FALSE` to -#' disable persistence. The cache is capped at 256 MB with least-recently-used -#' eviction; change the cap with `options(commons.context_cache_max_size)`. +#' Then configure MCP Console to build `commons` at startup and to mention it +#' in its tool description. A configured description replaces the one MCP +#' Console generates, so start from an export of the resolved configuration: #' -#' @section Agent tools: -#' Depending on its semantic layer, context layer, data sources, and `mode`, a -#' commons agent receives some combination of these tools: +#' ```bash +#' uvx mcp-console config resolve --full > .agents/console/config.yaml +#' ``` +#' +#' In that file, set the startup code, and add the same sentence to the end of +#' the exported description: #' -#' * `search_pool` searches trusted calculations and semantic models. -#' * `search_catalog` searches a warehouse catalog. -#' * `call_measure` invokes an R measure. -#' * `call_metrics` invokes governed or warehouse-native metrics. -#' * `call_calculation` invokes an exact trusted query. -#' * `search_context` retrieves relevant business context. -#' * `load_skill` loads instructions for a task, such as explaining how commons -#' determines and displays answer trust. -#' * `describe_table` inspects a table or semantic model. -#' * `run_sql` executes a read-only SQL query. -#' * `run_r` executes R code to analyze results and render plots in the agent's -#' R session. +#' ```yaml +#' startup: +#' language: r +#' code: | +#' commons <- acme.metrics::commons() +#' .console$sql_connection(commons$connection()) +#' tool: +#' description: |- +#' # ...the exported description, unchanged... #' -#' With `mode = "trusted only"`, the agent never receives `run_sql` or `run_r`. +#' The R session has an object called `commons` that holds this team's +#' trusted code and context. Before analyzing data, run +#' `print(commons)`, and use its calculations where they apply in favor of +#' writing analysis code ad-hoc. +#' ``` #' -#' These model-facing tools should be considered private. Their constructors -#' are intentionally not exported, and their names, arguments, availability, -#' and behavior may change without notice. Application code should configure -#' an agent through `commons()` and its layer constructors rather than depend -#' on individual tools. +#' Claude Code shows the tool description only once the agent loads the tool, +#' so add the sentence to the end of `tool.instructions` as well. #' -#' @return An [ellmer::Chat] subclass. +#' @section Lifecycle: +#' Before commons 0.2.0, `commons()` built the agent itself, taking `client` +#' as its first argument. Calls in that form still work, with a deprecation +#' warning, and return an agent as before. Use [agent()] instead. #' #' @examples #' \dontrun{ -#' # A measure over local data computes directly in R. -#' sem <- semantic_layer( -#' measure( -#' "order_count", -#' "Count of orders.", -#' function() nrow(my_sales), -#' arguments = list() -#' ) -#' ) -#' agent <- commons( -#' ellmer::chat_anthropic(), -#' data_sources = data_source(sales = my_sales), -#' semantic_layer = sem +#' commons <- commons( +#' data_sources = data_source(warehouse = con), +#' semantic_layer = sem, +#' context_layer = context_layer(files = "notes.md") #' ) -#' agent$chat("How many orders are there?") +#' commons +#' commons$search("revenue by region") +#' commons$measure("revenue_by_region", arguments = list(region = "West")) #' -#' # A measure takes a connection as an argument named after a data source. -#' # `warehouse` isn't in `arguments`, so the model never sees it; commons -#' # supplies it when the measure runs. Bind model-supplied arguments through -#' # DBI so they're quoted safely. -#' con <- DBI::dbConnect(duckdb::duckdb()) -#' sem <- semantic_layer( -#' measure( -#' "revenue_by_region", -#' "Total revenue for a region.", -#' function(region, warehouse) { -#' DBI::dbGetQuery( -#' warehouse, -#' "SELECT sum(revenue) AS revenue FROM sales WHERE region = ?", -#' params = list(region) -#' ) -#' }, -#' arguments = list(region = ellmer::type_string("Sales region.")) -#' ) -#' ) -#' agent <- commons( -#' ellmer::chat_anthropic(), -#' data_sources = list(warehouse = data_source(con)), -#' semantic_layer = sem -#' ) -#' -#' # Objects that aren't data sources (a pins board, an API client) come from -#' # argument defaults in the measure, e.g. `board = pins::board_connect()`. -#' # See ?semantic_layer. +#' # The same object backs an agent: +#' agent <- agent(ellmer::chat_anthropic(), commons = commons) #' } -#' #' @export commons <- function( + ..., + data_sources, + semantic_layer = NULL, + context_layer = NULL +) { + if (is_legacy_commons_call(...)) { + args <- c( + rlang::list2(...), + if (!missing(data_sources)) list(data_sources = data_sources), + if (!missing(semantic_layer)) list(semantic_layer = semantic_layer), + if (!missing(context_layer)) list(context_layer = context_layer) + ) + return(rlang::exec( + commons_legacy, + !!!args, + call = rlang::current_env(), + user_env = rlang::caller_env() + )) + } + rlang::check_dots_empty() + rlang::check_required(data_sources) + new_commons(data_sources, semantic_layer, context_layer) +} + +# Before 0.2.0, commons() took a client first and returned the agent. +is_legacy_commons_call <- function(...) { + dots <- rlang::list2(...) + if ("client" %in% names(dots)) { + return(TRUE) + } + unnamed <- dots[!nzchar(rlang::names2(dots))] + length(unnamed) > 0 && inherits(unnamed[[1]], "Chat") +} + +commons_legacy <- function( client, data_sources, semantic_layer = NULL, @@ -190,393 +154,305 @@ commons <- function( mode = c("normal", "trusted only"), network = c("none", "full"), log = FALSE, - share_with = NULL + share_with = NULL, + call, + user_env ) { - rlang::check_dots_empty() - if (!inherits(client, "Chat")) { - cli::cli_abort( - "{.arg client} must be an {.cls ellmer::Chat}, e.g. from {.fn ellmer::chat_anthropic}." - ) - } - if (!is.null(client$get_system_prompt())) { - cli::cli_warn( - c( - "The system prompt set on {.arg client} is ignored; commons builds - its own.", - i = "Use {.arg instructions} to add to commons' prompt." - ) - ) - } - if (inherits(data_sources, "commons_trusted")) { - if (!is.null(semantic_layer) || !is.null(context_layer)) { - cli::cli_abort( - "Pass {.arg semantic_layer} and {.arg context_layer} to {.fn trusted}, - not {.fn commons}, when {.arg data_sources} is a {.fn trusted} object." - ) - } - trusted <- data_sources - } else { - trusted <- new_trusted(data_sources, semantic_layer, context_layer) - } - mode <- rlang::arg_match(mode) - network <- rlang::arg_match(network) - trusted_only <- identical(mode, "trusted only") - if (trusted_only && identical(network, "full")) { - cli::cli_abort( - "{.code network = \"full\"} can't be combined with - {.code mode = \"trusted only\"}, which has no R session." - ) - } - # Without run_r there is nothing to sandbox, so the host needs no support. - protection <- if (trusted_only) NULL else run_r_protection_mode() - check_instructions(instructions) - rlang::check_bool(log) - check_share_with(share_with) - - Commons$new( - client = client, - trusted = trusted, - network = network, - protection = protection, + lifecycle::deprecate_warn( + "0.2.0", + "commons(client)", + "agent()", + details = paste( + "Build the commons with `commons(data_sources = ...)`, then pass it to", + "`agent(client, commons = commons)`." + ), + env = call, + user_env = user_env + ) + rlang::check_dots_empty(call = call) + commons <- new_commons(data_sources, semantic_layer, context_layer, call = call) + agent( + client, + commons = commons, instructions = instructions, mode = mode, + network = network, log = log, share_with = share_with ) } -Commons <- R6::R6Class( - "Commons", - inherit = ellmer::Chat, - public = list( - initialize = function( - client, - trusted, - ..., - instructions = NULL, - mode = c("normal", "trusted only"), - network = c("none", "full"), - protection = NULL, - log = FALSE, - share_with = NULL - ) { - rlang::check_dots_empty() - super$initialize( - provider = client$get_provider(), - model = client$get_model_object(), - echo = "none" - ) - mode <- rlang::arg_match(mode) - network <- rlang::arg_match(network) - private$trusted_only <- identical(mode, "trusted only") - - state <- trusted_state_of(trusted) - sources <- state$sources - private$trusted_obj <- trusted - private$sources <- sources - private$context_layer <- state$context_layer - private$first_touch <- new.env(parent = emptyenv()) - private$definitions <- state$definitions - private$semantic_models <- state$semantic_models - private$calculations <- state$calculations - private$registry <- state$registry - private$fn_sources <- state$fn_sources - private$measure_provenance <- state$measure_provenance - private$measure_display <- state$measure_display - private$injections <- state$injections - private$tracing <- new_trajectory_tracing(log, share_with) - - local_commons_span( - "commons_agent_create", - attributes = list( - "commons.agent.n_data_sources" = length(sources), - "commons.agent.has_context_layer" = state$has_context_layer, - "commons.agent.n_measures" = length(state$registry), - "commons.agent.n_definitions" = nrow(private$definitions$defs), - "commons.agent.n_semantic_members" = nrow( - private$semantic_models$members - ), - "commons.agent.n_calculations" = length(private$calculations) - ) - ) - - private$corpus <- build_citation_corpus( - private$context_layer, - private$registry, - sources - ) - private$restore_reminder_pending <- FALSE - # Handles, the worker, and citation requests all serve the fallback - # path, which a trusted-only agent doesn't have. - if (!private$trusted_only) { - private$handles <- new_handle_store() - private$worker <- new_r_worker( - network, - protection %||% run_r_protection_mode() - ) - private$citation_request <- new.env(parent = emptyenv()) - private$citation_request$reminder <- citation_reminder_text() - } +new_commons <- function( + data_sources, + semantic_layer = NULL, + context_layer = NULL, + call = rlang::caller_env() +) { + data_sources <- as_data_sources(data_sources, call = call) + check_context_layer(context_layer, call = call) + semantic_layer <- semantic_layer %||% new_semantic_layer() + check_semantic_layer(semantic_layer, call = call) + TheCommons$new(commons_state(data_sources, semantic_layer, context_layer, call)) +} - commons_tools <- build_commons_tools(self, private) - if (private$trusted_only) { - check_trusted_tools(commons_tools) - } - self$register_tools(commons_tools) - self$set_system_prompt( - commons_system_prompt( - private$sources, - definitions = private$definitions, - instructions = instructions, - tools = commons_tools, - model = self$get_model(), - trusted_only = private$trusted_only - ) - ) +TheCommons <- R6::R6Class( + "commons", + public = list( + initialize = function(state) { + private$state <- state }, - add_turn = function(user, assistant, log_tokens = TRUE) { - if (turn_has_user_message(user) && !is.null(private$citation_request)) { - private$citation_request$requested <- FALSE - } - super$add_turn(user, assistant, log_tokens = log_tokens) + print = function(...) { + cat(commons_overview(private$state), sep = "\n") + invisible(self) }, - set_turns = function(value) { - private$restore_reminder_pending <- FALSE - super$set_turns(value) + search = function(query) { + s <- private$state + commons_text(search_pool_text( + s$registry, + s$definitions, + query, + if (length(s$sources) > 1) names(s$sources) else character(), + semantic_models = s$semantic_models, + calculations = s$calculations, + methods = TRUE + )) }, - chat = function(..., echo = NULL) { - if (private$tracing) { - local_conversation_turn_span() - } - restore_reminder_pending <- private$restore_reminder_pending - inputs <- private$prepare_turn_inputs(rlang::list2(...)) - result <- withVisible(do.call(super$chat, c(inputs, list(echo = echo)))) - private$consume_restore_reminder(restore_reminder_pending) - if (result$visible) result$value else invisible(result$value) + measure = function(name, arguments = list()) { + s <- private$state + run_measure(s$registry, name, arguments, s$injections, s$sources)$value }, - stream_async = function( - ..., - tool_mode = c("concurrent", "sequential"), - stream = c("text", "content"), - controller = NULL + metrics = function( + metrics, + dimensions = NULL, + filters = NULL, + where = NULL, + arguments = list(), + source = NULL ) { - from_index <- length(self$get_turns()) + 1L - stream <- rlang::arg_match(stream) - restore_reminder_pending <- private$restore_reminder_pending - inputs <- private$prepare_turn_inputs(rlang::list2(...)) - raw_stream <- do.call( - super$stream_async, - c( - inputs, - list( - tool_mode = tool_mode, - stream = stream, - controller = controller - ) - ) - ) - - tracing <- private$tracing - trusted_only <- private$trusted_only - # A trusted-only agent shows no provenance markers. An empty corpus still - # strips citation markup but rejects every citation, so none renders. - corpus <- if (trusted_only) list() else private$corpus - as_content <- identical(stream, "content") - - # Always project citations so reserved model markup cannot reach the browser. - coro::async_generator(function() { - span <- NULL - if (tracing) { - span <- local_conversation_turn_span() - } - scanner <- citation_scanner(corpus) - - for (chunk in coro::await_each(raw_stream)) { - if (is.character(chunk)) { - out <- scanner$feed(chunk) - if (nzchar(out)) yield(out) - } else if (S7::S7_inherits(chunk, ellmer::ContentText)) { - out <- scanner$feed(chunk@text) - if (nzchar(out)) yield(ellmer::ContentText(out)) - } else { - yield(chunk) - } - } - - private$consume_restore_reminder(restore_reminder_pending) - - tail <- scanner$finish() - if (nzchar(tail)) { - yield(if (as_content) ellmer::ContentText(tail) else tail) - } - - decisions <- scanner$decisions() - verified <- any(vapply( - decisions, - function(d) identical(d$status, "accepted"), - logical(1) - )) - turns <- self$get_turns() - tag <- derive_provenance_tag( - collect_appended_tags(turns, from_index), - verified - ) - - if (tracing) { - # An absent provenance tag must not suppress the citation audit record. - if (!is.na(tag)) { - tryCatch( - commons_span_set_attribute(span, "commons.provenance.tag", tag), - error = function(err) NULL - ) - } - tryCatch( - commons_span_set_attribute( - span, - "commons.citation.candidates", - jsonlite::toJSON(decisions, auto_unbox = TRUE) - ), - error = function(err) NULL - ) - tryCatch( - record_provenance_span(tag, decisions), - error = function(err) NULL - ) - } + s <- private$state + query_metrics( + s$definitions, + s$sources, + metrics = metrics, + dimensions = dimensions, + filters = filters, + where = as_where_triples(where), + source_name = source, + arguments = arguments + )$result + }, - # A trusted-only agent shows no provenance marker. (coro generators - # can't assign the result of an `if` expression.) - display_tag <- tag - if (trusted_only) { - display_tag <- NA_character_ - } - aside <- provenance_aside(display_tag) - if (nzchar(aside)) { - yield(if (as_content) ellmer::ContentText(aside) else aside) - } - coro::exhausted() - })() + calculation = function(name, arguments = list(), source = NULL) { + run_calculation(private$state$sources, name, arguments, source)$result }, - citation_corpus = function() { - private$corpus + context = function(query) { + result <- search_context_tool(private$state$context_layer, query) + commons_text(tool_result_text(result)) }, - queue_restore_reminder = function() { - private$restore_reminder_pending <- TRUE - invisible(self) + describe = function(table, source = NULL) { + src <- resolve_sql_source(private$state$sources, source) + commons_text(tool_result_text(describe_table_tool(src, table))) }, - prewarm = function() { - # A direct call is typically warming caches ahead of deployment, so - # failures propagate: a cold cache should fail the deploy. - # prewarm_on_idle() downgrades them to warnings. - private$prewarm_context() - private$prewarm_sources() - invisible(self) + connection = function(source = NULL) { + src <- resolve_sql_source(private$state$sources, source) + data_source_state(src)$con } ), - active = list( - trusted = function() private$trusted_obj - ), - private = list( - prewarm_context = function() { - layer <- private$context_layer - layer_state <- if (is.null(layer)) NULL else context_layer_state(layer) - if (!is.null(layer_state) && length(layer_state$docs) > 0) { - local_commons_span( - "commons_context_prewarm", - attributes = list( - "commons.context.n_docs" = length(layer_state$docs), - # tryCatch: telemetry must not abort prewarming (resolving the - # cache dir can fail or warn on an unwritable root). - "commons.context.cache_hit" = - !is.null(layer_state$store) || - isTRUE(tryCatch( - context_cache_enabled() && - file.exists(context_store_path(layer_state$docs)), - error = function(err) FALSE - )) - ) - ) - context_store(layer) - } - invisible(self) + private = list(state = NULL) +) + +commons_state_of <- function(x) object_private(x)$state + +commons_state <- function(data_sources, semantic_layer, context_layer, call) { + semantic_state <- semantic_layer_state(semantic_layer) + list( + sources = data_sources, + context_layer = augment_context_layer(context_layer, data_sources), + has_context_layer = !is.null(context_layer), + definitions = definitions_registry(data_sources), + semantic_models = semantic_models_registry(data_sources), + calculations = calculations_registry(data_sources), + registry = semantic_state$measures, + fn_sources = semantic_state$fn_sources, + measure_provenance = semantic_state$measure_provenance, + measure_display = semantic_state$measure_display, + injections = resolve_injections( + semantic_state$measures, + measure_injectables(data_sources), + call = call + ) + ) +} + +commons_overview <- function(state) { + tables <- unlist(lapply(state$sources, function(source) { + tables <- data_source_state(source)$tables + if (is.character(tables)) tables else names(tables) + })) + n_metrics <- nrow(registry_defs(state$definitions)) + + nrow(registry_semantic_members(state$semantic_models)) + measures <- vapply( + state$registry, + function(td) sprintf(" - %s: %s", tool_name(td), tool_description(td)), + character(1) + ) + runners <- c("measure", "metrics", "calculation")[ + c(length(measures) > 0, n_metrics > 0, length(state$calculations) > 0) + ] + run_with <- cli::ansi_collapse( + sprintf("`$%s()`", runners), + sep2 = " or ", + last = ", or " + ) + c( + "", + sprintf( + "Sources: %s. Tables: %s.", + paste(names(state$sources) %||% "1 unnamed", collapse = ", "), + if (length(tables)) paste(tables, collapse = ", ") else "none listed" + ), + if (length(measures)) c("Measures:", measures), + if (n_metrics) { + sprintf("Governed metrics: %d. Find them with `$search()`.", n_metrics) + }, + if (length(state$calculations)) { + sprintf( + "Exact trusted queries: %d. Find them with `$search()`.", + length(state$calculations) + ) }, + if (state$has_context_layer) "Business context is available.", + "Methods:", + commons_method_signatures( + hide = setdiff(c("measure", "metrics", "calculation"), runners) + ), + strwrap(paste( + "This object provides access to trusted code and context.", + if (length(runners)) { + paste( + "Before writing your own analysis code or SQL, look for existing", + "code with `$search()` and run it with", paste0(run_with, "."), + "When none fits, check `$context(\"your query\")` for guidance on", + "the approach." + ) + } else { + paste( + "Before writing your own analysis code or SQL, check", + "`$context(\"your query\")` for guidance on the approach." + ) + }, + "Say in your answer which numbers came from trusted calculations and", + "which didn't." + )) + ) +} - prewarm_sources = function() { - for (source in private$sources) { - source_prewarm(source) - } - invisible(self) +# Methods that run a kind of calculation the object has none of are hidden. +commons_method_signatures <- function(hide = character()) { + methods <- TheCommons$public_methods + methods <- methods[ + setdiff(names(methods), c("initialize", "print", "clone", hide)) + ] + vapply( + names(methods), + function(name) { + formals <- formals(methods[[name]]) + args <- vapply( + names(formals), + function(arg) { + if (rlang::is_missing(formals[[arg]])) { + arg + } else { + paste(arg, "=", deparse(formals[[arg]])) + } + }, + character(1) + ) + sprintf(" $%s(%s)", name, paste(args, collapse = ", ")) }, + character(1), + USE.NAMES = FALSE + ) +} - trusted_obj = NULL, - sources = NULL, - trusted_only = FALSE, - context_layer = NULL, - registry = NULL, - definitions = NULL, - semantic_models = NULL, - calculations = NULL, - fn_sources = NULL, - measure_provenance = NULL, - measure_display = NULL, - injections = NULL, - tracing = FALSE, - first_touch = NULL, - handles = NULL, - worker = NULL, - corpus = NULL, - citation_request = NULL, - restore_reminder_pending = FALSE, +tool_result_text <- function(result) { + if (S7::S7_inherits(result, ellmer::ContentToolResult)) result@value else result +} - prepare_turn_inputs = function(inputs) { - inputs <- append_turn_reminder(inputs, self$get_model()) - # The restore reminder is about run_r state, which this mode never has. - if (private$restore_reminder_pending && !private$trusted_only) { - inputs <- append_restored_conversation_reminder(inputs) - } - inputs - }, +commons_text <- function(text) { + structure(paste(text, collapse = "\n"), class = "commons_text") +} + +#' @export +print.commons_text <- function(x, ...) { + cat(x, "\n", sep = "") + invisible(x) +} - consume_restore_reminder = function(was_pending) { - if (was_pending) { - private$restore_reminder_pending <- FALSE - } - invisible(NULL) +# Accepts the shapes models write first: a SQL-like string or unnamed triples. +as_where_triples <- function(where, call = rlang::caller_env()) { + if (is.null(where) || is.data.frame(where) || !is.null(names(where))) { + return(where) + } + if (rlang::is_string(where)) { + return(parse_where_string(where, call = call)) + } + if (is.character(where) && length(where) == 3) { + where <- list(where) + } + lapply(where, function(triple) { + if (!is.null(names(triple))) { + return(triple) } - ) -) + if (length(triple) != 3) { + abort_where(call) + } + rlang::set_names(as.list(triple), c("column", "op", "value")) + }) +} -# A trusted-only agent answers only with trusted calculations, so one that -# has none could answer nothing. -check_trusted_tools <- function(tools) { - names <- vapply(tools, tool_name, character(1)) - if (any(c("call_measure", "call_metrics", "call_calculation") %in% names)) { - return(invisible(NULL)) +parse_where_string <- function(where, call = rlang::caller_env()) { + condition <- paste0( + "^\\s*\"?([A-Za-z_][A-Za-z0-9_]*)\"?\\s*(<=|>=|!=|<>|=|<|>)\\s*", + "('(?:[^']|'')*'|-?[0-9]+(?:\\.[0-9]+)?)\\s*(?:(?i:AND)\\s+|$)" + ) + rest <- where + triples <- list() + while (nzchar(trimws(rest))) { + match <- regmatches(rest, regexec(condition, rest, perl = TRUE))[[1]] + if (length(match) == 0) { + abort_where(call) + } + value <- match[[4]] + if (startsWith(value, "'")) { + value <- gsub("''", "'", substr(value, 2, nchar(value) - 1)) + } + op <- if (identical(match[[3]], "<>")) "!=" else match[[3]] + triples[[length(triples) + 1]] <- list( + column = match[[2]], + op = op, + value = value + ) + rest <- substring(rest, nchar(match[[1]]) + 1) } + triples +} + +abort_where <- function(call) { cli::cli_abort( c( - "{.code mode = \"trusted only\"} needs at least one trusted - calculation.", - i = "Add measures to {.arg semantic_layer}, governed metric - definitions to a data dictionary, or a warehouse semantic model." + "{.arg where} must be simple conditions on documented columns.", + i = "Pass a string like {.code \"project = 'dplyr' AND date >= '2026-06-01'\"}, + or a list of {.code c(column, op, value)} triples.", + i = "Operators: {.code {where_ops}}, joined by {.code AND}." ), - call = NULL + call = call ) } - -turn_has_user_message <- function(turn) { - any(!vapply(turn@contents, is_tool_result_content, logical(1))) -} - -# Measures can take a named source's connection as an argument. -measure_injectables <- function(sources) { - named <- sources[rlang::have_name(sources)] - lapply(named, function(source) data_source_state(source)$con) -} diff --git a/pkg-r/R/context-layer.R b/pkg-r/R/context-layer.R index 581c31d4e..93b37ee14 100644 --- a/pkg-r/R/context-layer.R +++ b/pkg-r/R/context-layer.R @@ -1,10 +1,10 @@ #' Create a context layer #' -#' A context layer contains text that helps a [commons()] agent interpret its +#' A context layer contains text that helps a commons agent interpret its #' data source. #' #' Context is retrieved when relevant. Facts needed in every conversation -#' belong in the `instructions` passed to [commons()], not here. +#' belong in the `instructions` passed to [agent()], not here. #' #' @param files Character vector of paths to text or Markdown files. #' diff --git a/pkg-r/R/data-source.R b/pkg-r/R/data-source.R index 60354746e..6b8d2755d 100644 --- a/pkg-r/R/data-source.R +++ b/pkg-r/R/data-source.R @@ -1,6 +1,6 @@ #' Create a data source #' -#' A data source is the set of tables available to a [commons()] agent. +#' A data source is the set of tables gathered by [commons()]. #' #' `data_source()` accepts data in several forms, picked by the class of what #' you pass: @@ -13,7 +13,7 @@ #' in-process database: each pin in `tables` becomes a table. Pin names are #' validated against the board at construction (a single listing call), but #' each pin is downloaded only when its table is first used. Calling the -#' agent's `prewarm()` method (see [commons()]) starts a background process +#' agent's `prewarm()` method (see [agent()]) starts a background process #' that downloads the remaining pins into the local #' pins cache, so a first use typically only reads an already-downloaded #' file. Since the pins cache is on disk, `prewarm()` can also run diff --git a/pkg-r/R/measures.R b/pkg-r/R/measures.R index 2fee8e6f4..1e91b6d68 100644 --- a/pkg-r/R/measures.R +++ b/pkg-r/R/measures.R @@ -1,6 +1,6 @@ #' Create a semantic layer #' -#' `semantic_layer()` collects trusted calculations for a [commons()] agent. +#' `semantic_layer()` collects trusted calculations for [commons()]. #' Data dictionary definitions and warehouse semantic models contribute through #' [data_source()]. #' @@ -68,10 +68,12 @@ #' # DBI::dbGetQuery(warehouse, ...) #' # } #' -#' agent <- commons( +#' agent <- agent( #' ellmer::chat_anthropic(), -#' data_sources = list(warehouse = data_source(DBI::dbConnect(...))), -#' semantic_layer = semantic_layer("R/semantic_layer.R") +#' commons = commons( +#' data_sources = list(warehouse = data_source(DBI::dbConnect(...))), +#' semantic_layer = semantic_layer("R/semantic_layer.R") +#' ) #' ) #' } #' diff --git a/pkg-r/R/pool.R b/pkg-r/R/pool.R index 08972c6b7..0262d9c34 100644 --- a/pkg-r/R/pool.R +++ b/pkg-r/R/pool.R @@ -510,7 +510,7 @@ compile_where_triple <- function(triple, columns, con) { # One discovery surface over the whole pool: measures and definitions ranked # together, so the model doesn't have to guess which kind holds its answer. -# `methods` phrases usage as `trusted()` methods rather than agent tools. +# `methods` phrases usage as `commons()` methods rather than agent tools. search_pool_text <- function( measures, registry, diff --git a/pkg-r/R/trajectory-read.R b/pkg-r/R/trajectory-read.R index 086ae8106..59510f622 100644 --- a/pkg-r/R/trajectory-read.R +++ b/pkg-r/R/trajectory-read.R @@ -2,8 +2,8 @@ #' #' @description #' `trajectory_read()` reads conversation trajectories captured by -#' [commons()] when `log = TRUE`. Trajectories are recorded as OpenTelemetry -#' spans—see the `log` argument of [commons()] for how capture is +#' [agent()] when `log = TRUE`. Trajectories are recorded as OpenTelemetry +#' spans—see the `log` argument of [agent()] for how capture is #' enabled—and read back from Posit Connect's content observability store or #' from local trace files. #' @@ -14,7 +14,7 @@ #' * `NULL` (the default) resolves automatically: on Posit Connect, this #' content's own traces; in a project that has been deployed with #' rsconnect, the deployed content's traces; otherwise, the local trace -#' directory that [commons()] writes to. +#' directory that [agent()] writes to. #' * A Connect content GUID, a content URL (`.../content//`), a #' vanity URL (`.../content//`), or a dashboard URL #' (`.../connect/#/apps//`). @@ -35,7 +35,7 @@ #' URL, the project's deployment record, or the sole Connect server registered #' with rsconnect), and editor-level access to the content: you must own it or #' be a collaborator. See the `share_with` argument of -#' [commons()]. +#' [agent()]. #' #' @return A list of conversations, named by conversation id and ordered #' oldest-first. Each conversation is a list with a `turns` field containing diff --git a/pkg-r/R/trusted.R b/pkg-r/R/trusted.R deleted file mode 100644 index 1e6f54da0..000000000 --- a/pkg-r/R/trusted.R +++ /dev/null @@ -1,377 +0,0 @@ -#' Trusted calculations and context -#' -#' `trusted()` gathers a team's data sources, semantic layer, and context layer -#' into one object that code can call directly, without a model. Pass it to -#' `commons()` to build an agent in R, or place it in an R session that a -#' coding agent already controls. -#' -#' @inheritParams commons -#' -#' @return A `commons_trusted` object with methods: -#' -#' * `$search(query)` searches trusted calculations. -#' * `$measure(name, arguments)` runs a measure. -#' * `$metrics(metrics, dimensions, filters, where, arguments, source)` -#' computes governed metrics. `where` takes simple conditions on documented -#' columns, either as a string like -#' `"project = 'dplyr' AND date >= '2026-06-01'"` or as a list of -#' `c(column, op, value)` triples. -#' * `$calculation(name, arguments, source)` runs an exact trusted query. -#' * `$context(query)` searches trusted context. -#' * `$describe(table, source)` describes a table. -#' * `$connection(source)` returns a source's DBI connection, for queries -#' that no trusted calculation answers. -#' -#' @section Using commons with other coding agents: -#' `trusted()` is the entry point to use commons with external coding agents. -#' The approach depends on whether the coding agent already has a persistent -#' R session. For example, Posit Assistant connects to the user's R session. -#' Claude Code and Codex, by contrast, do not have a built-in connection to a -#' persistent R REPL. -#' -#' **For agents that already have an R session, like Posit Assistant**, create -#' a `trusted()` object in the relevant `.Rprofile` and then add a line telling -#' the agent print it out before doing data analysis: -#' -#' ```markdown -#' The R session has an object called `trusted` that holds this team's trusted -#' code and context. Before analyzing data, run `print(trusted)`, and use its -#' calculations where they apply in favor of writing analysis code ad-hoc. -#' ``` -#' -#' **Claude Code and Codex have no persistent R session of their own.** They can -#' get one through [MCP Console](https://t-kalinowski.github.io/mcp-console/). -#' MCP Console can be configured to run code at startup (which we'll use to -#' build `trusted`), and we'll also edit the console tool's description to tell -#' the model to print out `trusted` before analyzing data. -#' -#' To use MCP console, register it for the project, from the project directory: -#' -#' ```bash -#' uvx mcp-console configure-client claude --directory . -#' uvx mcp-console configure-client codex --directory . -#' ``` -#' -#' Then configure MCP Console to build `trusted` at startup and to mention it -#' in its tool description. A configured description replaces the one MCP -#' Console generates, so start from an export of the resolved configuration: -#' -#' ```bash -#' uvx mcp-console config resolve --full > .agents/console/config.yaml -#' ``` -#' -#' In that file, set the startup code, and add the same sentence to the end of -#' the exported description: -#' -#' ```yaml -#' startup: -#' language: r -#' code: | -#' trusted <- acme.metrics::trusted() -#' .console$sql_connection(trusted$connection()) -#' tool: -#' description: |- -#' # ...the exported description, unchanged... -#' -#' The R session has an object called `trusted` that holds this team's -#' trsuted code and context. Before analyzing data, run -#' `print(trusted)`, and use its calculations where they apply in favor of -#' writing analysis code ad-hoc. -#' ``` -#' -#' Claude Code shows the tool description only once the agent loads the tool, -#' so add the sentence to the end of `tool.instructions` as well. -#' -#' @examples -#' \dontrun{ -#' tr <- trusted( -#' data_source(warehouse = con), -#' semantic_layer = sem, -#' context_layer = context_layer(files = "notes.md") -#' ) -#' tr -#' tr$search("revenue by region") -#' tr$measure("revenue_by_region", arguments = list(region = "West")) -#' -#' # The same object backs an agent: -#' agent <- commons(ellmer::chat_anthropic(), tr) -#' } -#' @export -trusted <- function(data_sources, semantic_layer = NULL, context_layer = NULL) { - new_trusted(data_sources, semantic_layer, context_layer) -} - -new_trusted <- function( - data_sources, - semantic_layer = NULL, - context_layer = NULL, - call = rlang::caller_env() -) { - data_sources <- as_data_sources(data_sources, call = call) - check_context_layer(context_layer, call = call) - semantic_layer <- semantic_layer %||% new_semantic_layer() - check_semantic_layer(semantic_layer, call = call) - Trusted$new(trusted_state(data_sources, semantic_layer, context_layer, call)) -} - -Trusted <- R6::R6Class( - "commons_trusted", - public = list( - initialize = function(state) { - private$state <- state - }, - - print = function(...) { - cat(trusted_overview(private$state), sep = "\n") - invisible(self) - }, - - search = function(query) { - s <- private$state - trusted_text(search_pool_text( - s$registry, - s$definitions, - query, - if (length(s$sources) > 1) names(s$sources) else character(), - semantic_models = s$semantic_models, - calculations = s$calculations, - methods = TRUE - )) - }, - - measure = function(name, arguments = list()) { - s <- private$state - run_measure(s$registry, name, arguments, s$injections, s$sources)$value - }, - - metrics = function( - metrics, - dimensions = NULL, - filters = NULL, - where = NULL, - arguments = list(), - source = NULL - ) { - s <- private$state - query_metrics( - s$definitions, - s$sources, - metrics = metrics, - dimensions = dimensions, - filters = filters, - where = as_where_triples(where), - source_name = source, - arguments = arguments - )$result - }, - - calculation = function(name, arguments = list(), source = NULL) { - run_calculation(private$state$sources, name, arguments, source)$result - }, - - context = function(query) { - result <- search_context_tool(private$state$context_layer, query) - trusted_text(tool_result_text(result)) - }, - - describe = function(table, source = NULL) { - src <- resolve_sql_source(private$state$sources, source) - trusted_text(tool_result_text(describe_table_tool(src, table))) - }, - - connection = function(source = NULL) { - src <- resolve_sql_source(private$state$sources, source) - data_source_state(src)$con - } - ), - private = list(state = NULL) -) - -trusted_state_of <- function(x) object_private(x)$state - -trusted_state <- function(data_sources, semantic_layer, context_layer, call) { - semantic_state <- semantic_layer_state(semantic_layer) - list( - sources = data_sources, - context_layer = augment_context_layer(context_layer, data_sources), - has_context_layer = !is.null(context_layer), - definitions = definitions_registry(data_sources), - semantic_models = semantic_models_registry(data_sources), - calculations = calculations_registry(data_sources), - registry = semantic_state$measures, - fn_sources = semantic_state$fn_sources, - measure_provenance = semantic_state$measure_provenance, - measure_display = semantic_state$measure_display, - injections = resolve_injections( - semantic_state$measures, - measure_injectables(data_sources), - call = call - ) - ) -} - -trusted_overview <- function(state) { - tables <- unlist(lapply(state$sources, function(source) { - tables <- data_source_state(source)$tables - if (is.character(tables)) tables else names(tables) - })) - n_metrics <- nrow(registry_defs(state$definitions)) + - nrow(registry_semantic_members(state$semantic_models)) - measures <- vapply( - state$registry, - function(td) sprintf(" - %s: %s", tool_name(td), tool_description(td)), - character(1) - ) - runners <- c("measure", "metrics", "calculation")[ - c(length(measures) > 0, n_metrics > 0, length(state$calculations) > 0) - ] - run_with <- cli::ansi_collapse( - sprintf("`$%s()`", runners), - sep2 = " or ", - last = ", or " - ) - c( - "", - sprintf( - "Sources: %s. Tables: %s.", - paste(names(state$sources) %||% "1 unnamed", collapse = ", "), - if (length(tables)) paste(tables, collapse = ", ") else "none listed" - ), - if (length(measures)) c("Measures:", measures), - if (n_metrics) { - sprintf("Governed metrics: %d. Find them with `$search()`.", n_metrics) - }, - if (length(state$calculations)) { - sprintf( - "Exact trusted queries: %d. Find them with `$search()`.", - length(state$calculations) - ) - }, - if (state$has_context_layer) "Business context is available.", - "Methods:", - trusted_method_signatures( - hide = setdiff(c("measure", "metrics", "calculation"), runners) - ), - strwrap(paste( - "This object provides access to trusted code and context.", - if (length(runners)) { - paste( - "Before writing your own analysis code or SQL, look for existing", - "code with `$search()` and run it with", paste0(run_with, "."), - "When none fits, check `$context(\"your query\")` for guidance on", - "the approach." - ) - } else { - paste( - "Before writing your own analysis code or SQL, check", - "`$context(\"your query\")` for guidance on the approach." - ) - }, - "Say in your answer which numbers came from trusted calculations and", - "which didn't." - )) - ) -} - -# Methods that run a kind of calculation the object has none of are hidden. -trusted_method_signatures <- function(hide = character()) { - methods <- Trusted$public_methods - methods <- methods[ - setdiff(names(methods), c("initialize", "print", "clone", hide)) - ] - vapply( - names(methods), - function(name) { - formals <- formals(methods[[name]]) - args <- vapply( - names(formals), - function(arg) { - if (rlang::is_missing(formals[[arg]])) { - arg - } else { - paste(arg, "=", deparse(formals[[arg]])) - } - }, - character(1) - ) - sprintf(" $%s(%s)", name, paste(args, collapse = ", ")) - }, - character(1), - USE.NAMES = FALSE - ) -} - -tool_result_text <- function(result) { - if (S7::S7_inherits(result, ellmer::ContentToolResult)) result@value else result -} - -trusted_text <- function(text) { - structure(paste(text, collapse = "\n"), class = "commons_trusted_text") -} - -#' @export -print.commons_trusted_text <- function(x, ...) { - cat(x, "\n", sep = "") - invisible(x) -} - -# Accepts the shapes models write first: a SQL-like string or unnamed triples. -as_where_triples <- function(where, call = rlang::caller_env()) { - if (is.null(where) || is.data.frame(where) || !is.null(names(where))) { - return(where) - } - if (rlang::is_string(where)) { - return(parse_where_string(where, call = call)) - } - if (is.character(where) && length(where) == 3) { - where <- list(where) - } - lapply(where, function(triple) { - if (!is.null(names(triple))) { - return(triple) - } - if (length(triple) != 3) { - abort_where(call) - } - rlang::set_names(as.list(triple), c("column", "op", "value")) - }) -} - -parse_where_string <- function(where, call = rlang::caller_env()) { - condition <- paste0( - "^\\s*\"?([A-Za-z_][A-Za-z0-9_]*)\"?\\s*(<=|>=|!=|<>|=|<|>)\\s*", - "('(?:[^']|'')*'|-?[0-9]+(?:\\.[0-9]+)?)\\s*(?:(?i:AND)\\s+|$)" - ) - rest <- where - triples <- list() - while (nzchar(trimws(rest))) { - match <- regmatches(rest, regexec(condition, rest, perl = TRUE))[[1]] - if (length(match) == 0) { - abort_where(call) - } - value <- match[[4]] - if (startsWith(value, "'")) { - value <- gsub("''", "'", substr(value, 2, nchar(value) - 1)) - } - op <- if (identical(match[[3]], "<>")) "!=" else match[[3]] - triples[[length(triples) + 1]] <- list( - column = match[[2]], - op = op, - value = value - ) - rest <- substring(rest, nchar(match[[1]]) + 1) - } - triples -} - -abort_where <- function(call) { - cli::cli_abort( - c( - "{.arg where} must be simple conditions on documented columns.", - i = "Pass a string like {.code \"project = 'dplyr' AND date >= '2026-06-01'\"}, - or a list of {.code c(column, op, value)} triples.", - i = "Operators: {.code {where_ops}}, joined by {.code AND}." - ), - call = call - ) -} diff --git a/pkg-r/_pkgdown.yml b/pkg-r/_pkgdown.yml index ca314d64a..f1f9429c3 100644 --- a/pkg-r/_pkgdown.yml +++ b/pkg-r/_pkgdown.yml @@ -21,8 +21,8 @@ reference: - title: Main functions description: The main constructor, plus the building blocks for a commons agent. contents: - - trusted - commons + - agent - data_source - semantic_layer - measure diff --git a/pkg-r/inst/app.R b/pkg-r/inst/app.R index 5ec8d2401..e26e3d0a2 100644 --- a/pkg-r/inst/app.R +++ b/pkg-r/inst/app.R @@ -95,16 +95,18 @@ ui <- page_chat( ) server <- function(input, output, session) { - agent <- commons( + agent <- agent( ellmer::chat_anthropic(), - log = TRUE, - data_sources = list( - warehouse = data_source( - observations = observations, - site_area = site_area - ) + commons = commons( + data_sources = list( + warehouse = data_source( + observations = observations, + site_area = site_area + ) + ), + semantic_layer = semantics ), - semantic_layer = semantics + log = TRUE ) commons_server("chat", agent) diff --git a/pkg-r/inst/demo.R b/pkg-r/inst/demo.R index 3a929d274..f68565fa5 100644 --- a/pkg-r/inst/demo.R +++ b/pkg-r/inst/demo.R @@ -102,13 +102,15 @@ ui <- shinychat::page_chat( ) server <- function(input, output, session) { - agent <- commons( + agent <- agent( ellmer::chat_anthropic(), - data_sources = list( - warehouse = data_source(stands = stands, surveys = surveys) - ), - semantic_layer = sem, - context_layer = context_layer(files = notes) + commons = commons( + data_sources = list( + warehouse = data_source(stands = stands, surveys = surveys) + ), + semantic_layer = sem, + context_layer = context_layer(files = notes) + ) ) commons_server("chat", agent) diff --git a/pkg-r/inst/examples/plot-app/app.R b/pkg-r/inst/examples/plot-app/app.R index 8ebd1516a..cbccbfba6 100644 --- a/pkg-r/inst/examples/plot-app/app.R +++ b/pkg-r/inst/examples/plot-app/app.R @@ -34,10 +34,12 @@ ui <- page_fillable( ) server <- function(input, output, session) { - agent <- commons( + agent <- agent( ellmer::chat_anthropic(), - data_sources = data_source(values = values), - semantic_layer = measures + commons = commons( + data_sources = data_source(values = values), + semantic_layer = measures + ) ) commons_server("chat", agent) diff --git a/pkg-r/inst/examples/snowflake/app.R b/pkg-r/inst/examples/snowflake/app.R index c4685bf11..ab0d8f555 100644 --- a/pkg-r/inst/examples/snowflake/app.R +++ b/pkg-r/inst/examples/snowflake/app.R @@ -46,9 +46,9 @@ server <- function(input, output, session) { print(identity) source <- data_source(con, tables = semantic_view) - agent <- commons( + agent <- agent( ellmer::chat_anthropic(), - data_sources = list(snowflake = source) + commons = commons(data_sources = list(snowflake = source)) ) commons_server("chat", agent) diff --git a/pkg-r/inst/skills/commons/SKILL.md b/pkg-r/inst/skills/commons/SKILL.md index b200e9386..99733f4d5 100644 --- a/pkg-r/inst/skills/commons/SKILL.md +++ b/pkg-r/inst/skills/commons/SKILL.md @@ -1,7 +1,7 @@ --- name: commons description: >- - Build and maintain self-service data agents created with Posit's commons R package. Use when the user mentions a commons data agent or a project uses commons package APIs such as commons(), data_source(), semantic_layer(), context_layer(), or trajectory_read(), including onboarding, commons data dictionaries, trusted-artifact extraction, evaluation, and trajectory-based improvement. Do not use for unrelated data dictionaries, generic AI agents, or common or shared resources. + Build and maintain self-service data agents created with Posit's commons R package. Use when the user mentions a commons data agent or a project uses commons package APIs such as agent(), commons(), data_source(), semantic_layer(), context_layer(), or trajectory_read(), including onboarding, commons data dictionaries, trusted-artifact extraction, evaluation, and trajectory-based improvement. Do not use for unrelated data dictionaries, generic AI agents, or common or shared resources. --- # commons @@ -31,7 +31,7 @@ A commons agent project follows this on-disk convention. The onboarding, extract ``` my-agent/ ├── app.R # Shiny entry point -├── agent.R # reusable commons() construction +├── agent.R # reusable agent() construction ├── deploy.R # prewarm context and deploy the app ├── DESCRIPTION ├── AGENTS.md # or the active coding agent's equivalent diff --git a/pkg-r/inst/skills/commons/references/extracting-from-artifacts.md b/pkg-r/inst/skills/commons/references/extracting-from-artifacts.md index 7fafde389..9afd2199b 100644 --- a/pkg-r/inst/skills/commons/references/extracting-from-artifacts.md +++ b/pkg-r/inst/skills/commons/references/extracting-from-artifacts.md @@ -17,7 +17,7 @@ The user decides which artifacts and calculations are trusted. Do not treat code ## Workflow -1. Locate the agent project. Find the `commons()` definition and the existing semantic layer, dictionaries (`dictionaries/*.yaml`), and context files. If no agent project is found, say so and stop — this reference does not scaffold one. +1. Locate the agent project. Find the `agent()` and `commons()` calls and the existing semantic layer, dictionaries (`dictionaries/*.yaml`), and context files. If no agent project is found, say so and stop — this reference does not scaffold one. 2. Establish runnable access. Treat extraction as a code-running workflow, not a static source review. Confirm access to the artifact, each data source it uses, and its runtime dependencies. Use the project's existing authentication and configuration mechanisms. If credentials are missing, ask the user to provide authorized, preferably read-only access through an appropriate secret-management mechanism. Never write credential values to the agent project, agent instruction files, proposals, or logs. diff --git a/pkg-r/inst/skills/commons/references/iterating-from-trajectories.md b/pkg-r/inst/skills/commons/references/iterating-from-trajectories.md index 385b7617e..25ce4e98b 100644 --- a/pkg-r/inst/skills/commons/references/iterating-from-trajectories.md +++ b/pkg-r/inst/skills/commons/references/iterating-from-trajectories.md @@ -18,7 +18,7 @@ The answer uses SQL with little documentation and the agent had to inspect table ## Workflow 1. Find the agent definition. - Search for `commons(`, `data_source(`, `semantic_layer(`, `measure(`, and `context_layer(`. Identify where the semantic layer and context layer are constructed. If they are wrapped in project helpers, follow those helpers. Note that measure arguments without `@param` documentation are never seen by the model: an argument named after a data source receives that source's connection, and any other undocumented argument keeps its default. A new measure that queries a database should take the connection this way rather than referencing a global; other objects it needs (a pins board, an API client) should come from a default written as a call, e.g. `board = pins::board_connect()`. + Search for `agent(`, `commons(`, `data_source(`, `semantic_layer(`, `measure(`, and `context_layer(`. Identify where the semantic layer and context layer are constructed. If they are wrapped in project helpers, follow those helpers. Note that measure arguments without `@param` documentation are never seen by the model: an argument named after a data source receives that source's connection, and any other undocumented argument keeps its default. A new measure that queries a database should take the connection this way rather than referencing a global; other objects it needs (a pins board, an API client) should come from a default written as a call, e.g. `board = pins::board_connect()`. 2. Choose the evidence path. Do not always begin by loading raw trajectories. First look for a `commons-reviews/` directory. diff --git a/pkg-r/inst/skills/commons/references/onboarding.md b/pkg-r/inst/skills/commons/references/onboarding.md index c089d682a..07aa33e12 100644 --- a/pkg-r/inst/skills/commons/references/onboarding.md +++ b/pkg-r/inst/skills/commons/references/onboarding.md @@ -46,7 +46,7 @@ Reconcile new evidence with earlier assumptions and decisions as it appears. Sur ## Workflow 1. **Orient to commons.** Before scaffolding, identify the installed commons version and locate the corresponding package source when available. Read the relevant commons documentation and skill references, then inspect the implementation, examples, and tests for the APIs the agent will use. At minimum, examine: - - `commons()`, `data_source()`, `semantic_layer()`, and `context_layer()`; + - `agent()`, `commons()`, `data_source()`, `semantic_layer()`, and `context_layer()`; - `commons_theme()` and `commons_server()`, including per-session construction; - measure loading and data-dictionary behavior; and - dependency and deployment expectations for a commons app. @@ -106,7 +106,7 @@ Reconcile new evidence with earlier assumptions and decisions as it appears. Sur 9. **Revisit the assembled design.** Review the dictionaries, measures, governed definitions, and free-text context together against the confirmed scope, intended questions, source mapping, and data flow. Check for contradictions, duplication, unsupported claims, missing provenance, and information stored in the wrong layer. Reconsider the dictionary, semantic layer, and context layer design now that the extracted artifacts are visible. Treat consequential changes as decision points, and update the agent instruction file only when they change its durable guidance. -10. **Complete the agent.** Fill in `DESCRIPTION`, `agent.R`, and `app.R`. Ensure each Shiny session receives a fresh agent, as required by `commons_server()`. Construct it directly inside the server function or call reusable construction code from `agent.R`; do not pass one global agent object to every session. Ask the user which model provider the agent should use, and recommend enabling thinking in the ellmer chat passed to `commons()`. If the agent should answer only from trusted calculations and never write its own SQL or R, pass `mode = "trusted only"`. +10. **Complete the agent.** Fill in `DESCRIPTION`, `agent.R`, and `app.R`. Ensure each Shiny session receives a fresh agent, as required by `commons_server()`. Construct it directly inside the server function or call reusable construction code from `agent.R`; do not pass one global agent object to every session. Ask the user which model provider the agent should use, and recommend enabling thinking in the ellmer chat passed to `agent()`. If the agent should answer only from trusted calculations and never write its own SQL or R, pass `mode = "trusted only"`. A commons chat app must list `bsicons`, `htmltools`, `shiny`, and `shinychat` in `DESCRIPTION` under `Imports`. List any other packages the app calls directly rather than relying on transitive dependencies or `requireNamespace()` calls in `deploy.R` to make rsconnect discover them. @@ -166,7 +166,7 @@ Reconcile new evidence with earlier assumptions and decisions as it appears. Sur Adapt `build_agent()` and the rest of `app_files` to the project. The cache must be built before `app_files` is collected, and explicit file lists must include `commons-cache/`. Git-ignore the generated directory, but do not use `app_cache/`: rsconnect excludes that directory from bundles. Keep this deployment-only agent separate from the fresh agent constructed for each Shiny session. - When the agent uses `commons(log = TRUE)`, keep + When the agent uses `agent(log = TRUE)`, keep `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true` in the local deployment environment and include its name in `deployApp(envVars = ...)`, as above. This passes the value to the deployed content; setting it locally diff --git a/pkg-r/man/agent.Rd b/pkg-r/man/agent.Rd new file mode 100644 index 000000000..e7e345914 --- /dev/null +++ b/pkg-r/man/agent.Rd @@ -0,0 +1,212 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/agent.R +\name{agent} +\alias{agent} +\title{Create a commons agent} +\usage{ +agent( + client, + commons, + ..., + instructions = NULL, + mode = c("normal", "trusted only"), + network = c("none", "full"), + log = FALSE, + share_with = NULL +) +} +\arguments{ +\item{client}{An \link[ellmer:Chat]{ellmer::Chat} giving the provider and model to use, e.g. +\code{\link[ellmer:chat_anthropic]{ellmer::chat_anthropic()}}. For best results, enable thinking when +supported by the selected provider and model. A system prompt already set +on the client is ignored, with a warning; use \code{instructions} to add to +commons' prompt.} + +\item{commons}{A \code{\link[=commons]{commons()}} object holding the agent's data sources, +semantic layer, and context layer.} + +\item{...}{These dots are for future extensions and must be empty.} + +\item{instructions}{Optional instructions placed under an +\verb{## Additional instructions} heading at the end of commons' built-in +system prompt, as a single string or the path to a text or Markdown file. + +\if{html}{\out{
}}\preformatted{agent( + # ... + instructions = "Use the organization's fiscal-year conventions." +) +}\if{html}{\out{
}}} + +\item{mode}{Which analysis paths the agent may take. One of: +\itemize{ +\item \code{"normal"} (the default): the agent prefers trusted calculations and, +when none answers the question, falls back to writing its own SQL and R +code. +\item \code{"trusted only"}: the agent answers only with trusted calculations: +measures, governed metrics, and exact trusted queries. When none +answers the question, it says so rather than writing its own code. The +agent can still search context and inspect table schemas. Because +every answer comes from a trusted calculation, answers carry no +provenance markers, and the agent needs no sandboxed R session. The +agent must have at least one trusted calculation. +}} + +\item{network}{Whether the agent's R session has network access. One +of +\code{"none"} (the default) or \code{"full"}. Must be \code{"none"} when +\code{mode = "trusted only"}, since that mode has no R session. The session uses OS sandboxing on +Linux and macOS. On unsupported hosts, local development can opt in to +best-effort R guardrails with +\code{options(commons.allow_unsafe_fallback = TRUE)}. These guardrails +are not a security boundary.} + +\item{log}{Whether to request conversation trajectory capture with +OpenTelemetry (default \code{FALSE}). When \code{TRUE}, commons checks the tracing +setup and warns with setup steps when it is incomplete. This feature +requires Connect >= 2026.09.0. + +When deploying to Posit Connect, include +\code{"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"} in the \code{envVars} +argument to \code{\link[rsconnect:deployApp]{rsconnect::deployApp()}} so \pkg{ellmer} includes message +content. A server administrator must also set \code{OpenTelemetry.Enabled = true} +and \code{OpenTelemetry.AllowContentInstrumentation = true}. + +Once the agent is deployed and serving traffic, you can read conversation +histories back into R with \code{\link[=trajectory_read]{trajectory_read()}}.} + +\item{share_with}{An optional character vector of Connect usernames granted +access to this content's trajectories when running on Posit Connect. +Reading traces requires editor-level access, so named users are added as +collaborators on the content. Note that users whose Connect \emph{account} +role is viewer cannot read traces even when named here; trace readers +need at least a publisher account.} +} +\value{ +An \link[ellmer:Chat]{ellmer::Chat} subclass. +} +\description{ +\code{agent()} creates an \link[ellmer:Chat]{ellmer::Chat} subclass with tools and prompting that +allow the agent to navigate the data sources, semantic layer, and context +layer gathered by \code{\link[=commons]{commons()}}. Depending on the agent's choice of tools, +responses can be deterministically classified as based on a trusted +calculation, cited, or untrusted. +} +\details{ +The provider and model come from \code{client}; commons sets its own system prompt +and tools. Use \code{agent$chat()} to ask questions, \code{\link[=commons_theme]{commons_theme()}} and +\code{\link[=commons_server]{commons_server()}} to embed the agent in Shiny, and \code{\link[vitals:generate]{vitals::generate()}} +to use the agent as a vitals solver. +} +\section{Cache pre-warming}{ + +A commons agent builds its context search index and downloads uncached pins +the first time it needs them. \code{\link[=commons_server]{commons_server()}} and \code{\link[=commons_app]{commons_app()}} call the +agent's \code{prewarm()} method automatically during post-startup idle time. + +To warm the caches before deployment, call \code{agent$prewarm()} in a +pre-deploy script. The context index is cached on disk once per version of +the context documents; pin downloads populate the local pins cache. + +To ship a pre-built context index with an app, configure a directory inside +the app in both the pre-deploy script and the deployed app, then prewarm the +agent before deploying: + +\if{html}{\out{
}}\preformatted{options(commons.context_cache = "commons-cache") +agent <- agent( + ellmer::chat_anthropic(), + commons = commons(data_sources = data_source(sales = sales)) +) +agent$prewarm() +}\if{html}{\out{
}} + +Do not use \verb{app_cache/} for this workflow because rsconnect excludes it from +deployed bundles. Without explicit configuration, commons uses Connect's +persistent content data directory when available, an \verb{app_cache/} directory +beside hosted apps, or the per-user cache directory. Set the cache directory +with \code{options(commons.context_cache = "path/to/dir")} or the +\code{COMMONS_CONTEXT_CACHE} environment variable. Set the option to \code{FALSE} to +disable persistence. The cache is capped at 256 MB with least-recently-used +eviction; change the cap with \code{options(commons.context_cache_max_size)}. +} + +\section{Agent tools}{ + +Depending on its semantic layer, context layer, data sources, and \code{mode}, a +commons agent receives some combination of these tools: +\itemize{ +\item \code{search_pool} searches trusted calculations and semantic models. +\item \code{search_catalog} searches a warehouse catalog. +\item \code{call_measure} invokes an R measure. +\item \code{call_metrics} invokes governed or warehouse-native metrics. +\item \code{call_calculation} invokes an exact trusted query. +\item \code{search_context} retrieves relevant business context. +\item \code{load_skill} loads instructions for a task, such as explaining how commons +determines and displays answer trust. +\item \code{describe_table} inspects a table or semantic model. +\item \code{run_sql} executes a read-only SQL query. +\item \code{run_r} executes R code to analyze results and render plots in the agent's +R session. +} + +With \code{mode = "trusted only"}, the agent never receives \code{run_sql} or \code{run_r}. + +These model-facing tools should be considered private. Their constructors +are intentionally not exported, and their names, arguments, availability, +and behavior may change without notice. Application code should configure +an agent through \code{agent()}, \code{commons()}, and the layer constructors rather +than depend on individual tools. +} + +\examples{ +\dontrun{ +# A measure over local data computes directly in R. +sem <- semantic_layer( + measure( + "order_count", + "Count of orders.", + function() nrow(my_sales), + arguments = list() + ) +) +agent <- agent( + ellmer::chat_anthropic(), + commons = commons( + data_sources = data_source(sales = my_sales), + semantic_layer = sem + ) +) +agent$chat("How many orders are there?") + +# A measure takes a connection as an argument named after a data source. +# `warehouse` isn't in `arguments`, so the model never sees it; commons +# supplies it when the measure runs. Bind model-supplied arguments through +# DBI so they're quoted safely. +con <- DBI::dbConnect(duckdb::duckdb()) +sem <- semantic_layer( + measure( + "revenue_by_region", + "Total revenue for a region.", + function(region, warehouse) { + DBI::dbGetQuery( + warehouse, + "SELECT sum(revenue) AS revenue FROM sales WHERE region = ?", + params = list(region) + ) + }, + arguments = list(region = ellmer::type_string("Sales region.")) + ) +) +agent <- agent( + ellmer::chat_anthropic(), + commons = commons( + data_sources = list(warehouse = data_source(con)), + semantic_layer = sem + ) +) + +# Objects that aren't data sources (a pins board, an API client) come from +# argument defaults in the measure, e.g. `board = pins::board_connect()`. +# See ?semantic_layer. +} + +} diff --git a/pkg-r/man/commons.Rd b/pkg-r/man/commons.Rd index 505a780fa..df117be8e 100644 --- a/pkg-r/man/commons.Rd +++ b/pkg-r/man/commons.Rd @@ -2,215 +2,124 @@ % Please edit documentation in R/commons.R \name{commons} \alias{commons} -\title{Create a commons agent} +\title{Gather trusted calculations and context} \usage{ -commons( - client, - data_sources, - semantic_layer = NULL, - context_layer = NULL, - ..., - instructions = NULL, - mode = c("normal", "trusted only"), - network = c("none", "full"), - log = FALSE, - share_with = NULL -) +commons(..., data_sources, semantic_layer = NULL, context_layer = NULL) } \arguments{ -\item{client}{An \link[ellmer:Chat]{ellmer::Chat} giving the provider and model to use, e.g. -\code{\link[ellmer:chat_anthropic]{ellmer::chat_anthropic()}}. For best results, enable thinking when -supported by the selected provider and model. A system prompt already set -on the client is ignored, with a warning; use \code{instructions} to add to -commons' prompt.} +\item{...}{These dots are for future extensions and must be empty.} \item{data_sources}{A \code{\link[=data_source]{data_source()}}, or a named list of them. Measures can take a source's connection as an argument named after the source; see -\code{\link[=semantic_layer]{semantic_layer()}}. In \code{commons()}, this can also be a \code{\link[=trusted]{trusted()}} object, -in which case \code{semantic_layer} and \code{context_layer} must be \code{NULL}.} +\code{\link[=semantic_layer]{semantic_layer()}}.} \item{semantic_layer}{An optional \code{\link[=semantic_layer]{semantic_layer()}}.} \item{context_layer}{An optional \code{\link[=context_layer]{context_layer()}}.} - -\item{...}{These dots are for future extensions and must be empty.} - -\item{instructions}{Optional instructions placed under an -\verb{## Additional instructions} heading at the end of commons' built-in -system prompt, as a single string or the path to a text or Markdown file. - -\if{html}{\out{
}}\preformatted{commons( - # ... - instructions = "Use the organization's fiscal-year conventions." -) -}\if{html}{\out{
}}} - -\item{mode}{Which analysis paths the agent may take. One of: -\itemize{ -\item \code{"normal"} (the default): the agent prefers trusted calculations and, -when none answers the question, falls back to writing its own SQL and R -code. -\item \code{"trusted only"}: the agent answers only with trusted calculations: -measures, governed metrics, and exact trusted queries. When none -answers the question, it says so rather than writing its own code. The -agent can still search context and inspect table schemas. Because -every answer comes from a trusted calculation, answers carry no -provenance markers, and the agent needs no sandboxed R session. The -agent must have at least one trusted calculation. -}} - -\item{network}{Whether the agent's R session has network access. One -of -\code{"none"} (the default) or \code{"full"}. Must be \code{"none"} when -\code{mode = "trusted only"}, since that mode has no R session. The session uses OS sandboxing on -Linux and macOS. On unsupported hosts, local development can opt in to -best-effort R guardrails with -\code{options(commons.allow_unsafe_fallback = TRUE)}. These guardrails -are not a security boundary.} - -\item{log}{Whether to request conversation trajectory capture with -OpenTelemetry (default \code{FALSE}). When \code{TRUE}, commons checks the tracing -setup and warns with setup steps when it is incomplete. This feature -requires Connect >= 2026.09.0. - -When deploying to Posit Connect, include -\code{"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"} in the \code{envVars} -argument to \code{\link[rsconnect:deployApp]{rsconnect::deployApp()}} so \pkg{ellmer} includes message -content. A server administrator must also set \code{OpenTelemetry.Enabled = true} -and \code{OpenTelemetry.AllowContentInstrumentation = true}. - -Once the agent is deployed and serving traffic, you can read conversation -histories back into R with \code{\link[=trajectory_read]{trajectory_read()}}.} - -\item{share_with}{An optional character vector of Connect usernames granted -access to this content's trajectories when running on Posit Connect. -Reading traces requires editor-level access, so named users are added as -collaborators on the content. Note that users whose Connect \emph{account} -role is viewer cannot read traces even when named here; trace readers -need at least a publisher account.} } \value{ -An \link[ellmer:Chat]{ellmer::Chat} subclass. +A \code{commons} object with methods: +\itemize{ +\item \verb{$search(query)} searches trusted calculations. +\item \verb{$measure(name, arguments)} runs a measure. +\item \verb{$metrics(metrics, dimensions, filters, where, arguments, source)} +computes governed metrics. \code{where} takes simple conditions on documented +columns, either as a string like +\code{"project = 'dplyr' AND date >= '2026-06-01'"} or as a list of +\code{c(column, op, value)} triples. +\item \verb{$calculation(name, arguments, source)} runs an exact trusted query. +\item \verb{$context(query)} searches trusted context. +\item \verb{$describe(table, source)} describes a table. +\item \verb{$connection(source)} returns a source's DBI connection, for queries +that no trusted calculation answers. +} } \description{ -\code{commons()} creates an \link[ellmer:Chat]{ellmer::Chat} subclass with tools and prompting that -allow the agent to navigate its data sources, semantic layer, and context -layer. Depending on the agent's choice of tools, responses can be -deterministically classified as based on a trusted calculation, cited, or -untrusted. +\code{commons()} gathers a team's data sources, semantic layer, and context layer +into one object, "the commons", that code can call directly without a model. +Pass it to \code{\link[=agent]{agent()}} to build an agent in R, or place it in an R session +that a coding agent already controls. } \details{ -The provider and model come from \code{client}; commons sets its own system prompt -and tools. Use \code{agent$chat()} to ask questions, \code{\link[=commons_theme]{commons_theme()}} and -\code{\link[=commons_server]{commons_server()}} to embed the agent in Shiny, and \code{\link[vitals:generate]{vitals::generate()}} -to use the agent as a vitals solver. +All arguments must be named. } -\section{Cache pre-warming}{ +\section{Using commons with other coding agents}{ + +\code{commons()} is the entry point to use commons with external coding agents. +The approach depends on whether the coding agent already has a persistent +R session. For example, Posit Assistant connects to the user's R session. +Claude Code and Codex, by contrast, do not have a built-in connection to a +persistent R REPL. -A commons agent builds its context search index and downloads uncached pins -the first time it needs them. \code{\link[=commons_server]{commons_server()}} and \code{\link[=commons_app]{commons_app()}} call the -agent's \code{prewarm()} method automatically during post-startup idle time. +\strong{For agents that already have an R session, like Posit Assistant}, create +a \code{commons()} object in the relevant \code{.Rprofile} and then add a line telling +the agent to print it out before doing data analysis: -To warm the caches before deployment, call \code{agent$prewarm()} in a -pre-deploy script. The context index is cached on disk once per version of -the context documents; pin downloads populate the local pins cache. +\if{html}{\out{
}}\preformatted{The R session has an object called `commons` that holds this team's trusted +code and context. Before analyzing data, run `print(commons)`, and use its +calculations where they apply in favor of writing analysis code ad-hoc. +}\if{html}{\out{
}} -To ship a pre-built context index with an app, configure a directory inside -the app in both the pre-deploy script and the deployed app, then prewarm the -agent before deploying: +\strong{Claude Code and Codex have no persistent R session of their own.} They can +get one through \href{https://t-kalinowski.github.io/mcp-console/}{MCP Console}. +MCP Console can be configured to run code at startup (which we'll use to +build \code{commons}), and we'll also edit the console tool's description to tell +the model to print out \code{commons} before analyzing data. -\if{html}{\out{
}}\preformatted{options(commons.context_cache = "commons-cache") -agent <- commons( - ellmer::chat_anthropic(), - data_sources = data_source(sales = sales) -) -agent$prewarm() +To use MCP console, register it for the project, from the project directory: + +\if{html}{\out{
}}\preformatted{uvx mcp-console configure-client claude --directory . +uvx mcp-console configure-client codex --directory . }\if{html}{\out{
}} -Do not use \verb{app_cache/} for this workflow because rsconnect excludes it from -deployed bundles. Without explicit configuration, commons uses Connect's -persistent content data directory when available, an \verb{app_cache/} directory -beside hosted apps, or the per-user cache directory. Set the cache directory -with \code{options(commons.context_cache = "path/to/dir")} or the -\code{COMMONS_CONTEXT_CACHE} environment variable. Set the option to \code{FALSE} to -disable persistence. The cache is capped at 256 MB with least-recently-used -eviction; change the cap with \code{options(commons.context_cache_max_size)}. -} +Then configure MCP Console to build \code{commons} at startup and to mention it +in its tool description. A configured description replaces the one MCP +Console generates, so start from an export of the resolved configuration: -\section{Agent tools}{ +\if{html}{\out{
}}\preformatted{uvx mcp-console config resolve --full > .agents/console/config.yaml +}\if{html}{\out{
}} -Depending on its semantic layer, context layer, data sources, and \code{mode}, a -commons agent receives some combination of these tools: -\itemize{ -\item \code{search_pool} searches trusted calculations and semantic models. -\item \code{search_catalog} searches a warehouse catalog. -\item \code{call_measure} invokes an R measure. -\item \code{call_metrics} invokes governed or warehouse-native metrics. -\item \code{call_calculation} invokes an exact trusted query. -\item \code{search_context} retrieves relevant business context. -\item \code{load_skill} loads instructions for a task, such as explaining how commons -determines and displays answer trust. -\item \code{describe_table} inspects a table or semantic model. -\item \code{run_sql} executes a read-only SQL query. -\item \code{run_r} executes R code to analyze results and render plots in the agent's -R session. +In that file, set the startup code, and add the same sentence to the end of +the exported description: + +\if{html}{\out{
}}\preformatted{startup: + language: r + code: | + commons <- acme.metrics::commons() + .console$sql_connection(commons$connection()) +tool: + description: |- + # ...the exported description, unchanged... + + The R session has an object called `commons` that holds this team's + trusted code and context. Before analyzing data, run + `print(commons)`, and use its calculations where they apply in favor of + writing analysis code ad-hoc. +}\if{html}{\out{
}} + +Claude Code shows the tool description only once the agent loads the tool, +so add the sentence to the end of \code{tool.instructions} as well. } -With \code{mode = "trusted only"}, the agent never receives \code{run_sql} or \code{run_r}. +\section{Lifecycle}{ -These model-facing tools should be considered private. Their constructors -are intentionally not exported, and their names, arguments, availability, -and behavior may change without notice. Application code should configure -an agent through \code{commons()} and its layer constructors rather than depend -on individual tools. +Before commons 0.2.0, \code{commons()} built the agent itself, taking \code{client} +as its first argument. Calls in that form still work, with a deprecation +warning, and return an agent as before. Use \code{\link[=agent]{agent()}} instead. } \examples{ \dontrun{ -# A measure over local data computes directly in R. -sem <- semantic_layer( - measure( - "order_count", - "Count of orders.", - function() nrow(my_sales), - arguments = list() - ) -) -agent <- commons( - ellmer::chat_anthropic(), - data_sources = data_source(sales = my_sales), - semantic_layer = sem -) -agent$chat("How many orders are there?") - -# A measure takes a connection as an argument named after a data source. -# `warehouse` isn't in `arguments`, so the model never sees it; commons -# supplies it when the measure runs. Bind model-supplied arguments through -# DBI so they're quoted safely. -con <- DBI::dbConnect(duckdb::duckdb()) -sem <- semantic_layer( - measure( - "revenue_by_region", - "Total revenue for a region.", - function(region, warehouse) { - DBI::dbGetQuery( - warehouse, - "SELECT sum(revenue) AS revenue FROM sales WHERE region = ?", - params = list(region) - ) - }, - arguments = list(region = ellmer::type_string("Sales region.")) - ) -) -agent <- commons( - ellmer::chat_anthropic(), - data_sources = list(warehouse = data_source(con)), - semantic_layer = sem +commons <- commons( + data_sources = data_source(warehouse = con), + semantic_layer = sem, + context_layer = context_layer(files = "notes.md") ) +commons +commons$search("revenue by region") +commons$measure("revenue_by_region", arguments = list(region = "West")) -# Objects that aren't data sources (a pins board, an API client) come from -# argument defaults in the measure, e.g. `board = pins::board_connect()`. -# See ?semantic_layer. +# The same object backs an agent: +agent <- agent(ellmer::chat_anthropic(), commons = commons) } - } diff --git a/pkg-r/man/commons_app.Rd b/pkg-r/man/commons_app.Rd index d581b9e08..b204aa73d 100644 --- a/pkg-r/man/commons_app.Rd +++ b/pkg-r/man/commons_app.Rd @@ -7,7 +7,7 @@ commons_app(client, ...) } \arguments{ -\item{client}{A \code{\link[=commons]{commons()}} agent.} +\item{client}{An \code{\link[=agent]{agent()}}.} \item{...}{Extra arguments passed to \code{\link[shiny:shinyApp]{shiny::shinyApp()}}.} } @@ -31,9 +31,9 @@ cites nothing verified. \examples{ \dontrun{ -agent <- commons( +agent <- agent( ellmer::chat_anthropic(), - data_sources = data_source(sales = sales) + commons = commons(data_sources = data_source(sales = sales)) ) commons_app(agent) } diff --git a/pkg-r/man/commons_server.Rd b/pkg-r/man/commons_server.Rd index fa98e9cde..4b484fb33 100644 --- a/pkg-r/man/commons_server.Rd +++ b/pkg-r/man/commons_server.Rd @@ -13,7 +13,7 @@ commons_theme(..., preset = "shiny") \item{id}{The ID of the chat element; must match the \code{id} of the \code{\link[shinychat:page_chat]{shinychat::page_chat()}} or \code{\link[shinychat:chat_ui]{shinychat::chat_ui()}} on the page.} -\item{client}{A \code{\link[=commons]{commons()}} agent. In a deployed app, create the agent +\item{client}{An \code{\link[=agent]{agent()}}. In a deployed app, create the agent inside the server function and pass it to \code{commons_server()} so each Shiny session gets its own agent state.} @@ -47,9 +47,9 @@ ui <- page_chat("Assistant", id = "chat", theme = commons_theme()) server <- function(input, output, session) { # One agent per session, so each user gets their own agent state - agent <- commons( + agent <- agent( ellmer::chat_anthropic(), - data_sources = data_source(sales = sales) + commons = commons(data_sources = data_source(sales = sales)) ) commons_server("chat", agent) } diff --git a/pkg-r/man/context_layer.Rd b/pkg-r/man/context_layer.Rd index 5b324f292..fe69f2ea7 100644 --- a/pkg-r/man/context_layer.Rd +++ b/pkg-r/man/context_layer.Rd @@ -14,12 +14,12 @@ A \code{commons_context_layer} R6 object. Its internals are private and may change without notice. } \description{ -A context layer contains text that helps a \code{\link[=commons]{commons()}} agent interpret its +A context layer contains text that helps a commons agent interpret its data source. } \details{ Context is retrieved when relevant. Facts needed in every conversation -belong in the \code{instructions} passed to \code{\link[=commons]{commons()}}, not here. +belong in the \code{instructions} passed to \code{\link[=agent]{agent()}}, not here. } \examples{ path <- tempfile(fileext = ".md") diff --git a/pkg-r/man/data_source.Rd b/pkg-r/man/data_source.Rd index 4c60be748..798db52b9 100644 --- a/pkg-r/man/data_source.Rd +++ b/pkg-r/man/data_source.Rd @@ -48,7 +48,7 @@ A \code{commons_data_source} R6 object. Its internals are private and may change without notice. } \description{ -A data source is the set of tables available to a \code{\link[=commons]{commons()}} agent. +A data source is the set of tables gathered by \code{\link[=commons]{commons()}}. } \details{ \code{data_source()} accepts data in several forms, picked by the class of what @@ -62,7 +62,7 @@ when the data isn't already in a database. in-process database: each pin in \code{tables} becomes a table. Pin names are validated against the board at construction (a single listing call), but each pin is downloaded only when its table is first used. Calling the -agent's \code{prewarm()} method (see \code{\link[=commons]{commons()}}) starts a background process +agent's \code{prewarm()} method (see \code{\link[=agent]{agent()}}) starts a background process that downloads the remaining pins into the local pins cache, so a first use typically only reads an already-downloaded file. Since the pins cache is on disk, \code{prewarm()} can also run diff --git a/pkg-r/man/semantic_layer.Rd b/pkg-r/man/semantic_layer.Rd index 70e41a59b..e68984863 100644 --- a/pkg-r/man/semantic_layer.Rd +++ b/pkg-r/man/semantic_layer.Rd @@ -16,7 +16,7 @@ A \code{commons_semantic_layer} R6 object. Its internals are private and may change without notice. } \description{ -\code{semantic_layer()} collects trusted calculations for a \code{\link[=commons]{commons()}} agent. +\code{semantic_layer()} collects trusted calculations for \code{\link[=commons]{commons()}}. Data dictionary definitions and warehouse semantic models contribute through \code{\link[=data_source]{data_source()}}. } @@ -80,10 +80,12 @@ semantic_layer( # DBI::dbGetQuery(warehouse, ...) # } -agent <- commons( +agent <- agent( ellmer::chat_anthropic(), - data_sources = list(warehouse = data_source(DBI::dbConnect(...))), - semantic_layer = semantic_layer("R/semantic_layer.R") + commons = commons( + data_sources = list(warehouse = data_source(DBI::dbConnect(...))), + semantic_layer = semantic_layer("R/semantic_layer.R") + ) ) } diff --git a/pkg-r/man/trajectory_read.Rd b/pkg-r/man/trajectory_read.Rd index 69abd210f..0d4947ce6 100644 --- a/pkg-r/man/trajectory_read.Rd +++ b/pkg-r/man/trajectory_read.Rd @@ -12,7 +12,7 @@ trajectory_read(source = NULL, ..., n = NULL, from = NULL, to = NULL) \item \code{NULL} (the default) resolves automatically: on Posit Connect, this content's own traces; in a project that has been deployed with rsconnect, the deployed content's traces; otherwise, the local trace -directory that \code{\link[=commons]{commons()}} writes to. +directory that \code{\link[=agent]{agent()}} writes to. \item A Connect content GUID, a content URL (\verb{.../content//}), a vanity URL (\verb{.../content//}), or a dashboard URL (\verb{.../connect/#/apps//}). @@ -39,8 +39,8 @@ a list of \link[ellmer:Turn]{ellmer::Turn}s and a \code{last_active} field conta } \description{ \code{trajectory_read()} reads conversation trajectories captured by -\code{\link[=commons]{commons()}} when \code{log = TRUE}. Trajectories are recorded as OpenTelemetry -spans—see the \code{log} argument of \code{\link[=commons]{commons()}} for how capture is +\code{\link[=agent]{agent()}} when \code{log = TRUE}. Trajectories are recorded as OpenTelemetry +spans—see the \code{log} argument of \code{\link[=agent]{agent()}} for how capture is enabled—and read back from Posit Connect's content observability store or from local trace files. @@ -52,7 +52,7 @@ variable (and \code{CONNECT_SERVER}, when the server can't be inferred from the URL, the project's deployment record, or the sole Connect server registered with rsconnect), and editor-level access to the content: you must own it or be a collaborator. See the \code{share_with} argument of -\code{\link[=commons]{commons()}}. +\code{\link[=agent]{agent()}}. } \examples{ \dontrun{ diff --git a/pkg-r/man/trusted.Rd b/pkg-r/man/trusted.Rd deleted file mode 100644 index e856c0727..000000000 --- a/pkg-r/man/trusted.Rd +++ /dev/null @@ -1,114 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/trusted.R -\name{trusted} -\alias{trusted} -\title{Trusted calculations and context} -\usage{ -trusted(data_sources, semantic_layer = NULL, context_layer = NULL) -} -\arguments{ -\item{data_sources}{A \code{\link[=data_source]{data_source()}}, or a named list of them. Measures -can take a source's connection as an argument named after the source; see -\code{\link[=semantic_layer]{semantic_layer()}}. In \code{commons()}, this can also be a \code{\link[=trusted]{trusted()}} object, -in which case \code{semantic_layer} and \code{context_layer} must be \code{NULL}.} - -\item{semantic_layer}{An optional \code{\link[=semantic_layer]{semantic_layer()}}.} - -\item{context_layer}{An optional \code{\link[=context_layer]{context_layer()}}.} -} -\value{ -A \code{commons_trusted} object with methods: -\itemize{ -\item \verb{$search(query)} searches trusted calculations. -\item \verb{$measure(name, arguments)} runs a measure. -\item \verb{$metrics(metrics, dimensions, filters, where, arguments, source)} -computes governed metrics. \code{where} takes simple conditions on documented -columns, either as a string like -\code{"project = 'dplyr' AND date >= '2026-06-01'"} or as a list of -\code{c(column, op, value)} triples. -\item \verb{$calculation(name, arguments, source)} runs an exact trusted query. -\item \verb{$context(query)} searches trusted context. -\item \verb{$describe(table, source)} describes a table. -\item \verb{$connection(source)} returns a source's DBI connection, for queries -that no trusted calculation answers. -} -} -\description{ -\code{trusted()} gathers a team's data sources, semantic layer, and context layer -into one object that code can call directly, without a model. Pass it to -\code{commons()} to build an agent in R, or place it in an R session that a -coding agent already controls. -} -\section{Using commons with other coding agents}{ - -\code{trusted()} is the entry point to use commons with external coding agents. -The approach depends on whether the coding agent already has a persistent -R session. For example, Posit Assistant connects to the user's R session. -Claude Code and Codex, by contrast, do not have a built-in connection to a -persistent R REPL. - -\strong{For agents that already have an R session, like Posit Assistant}, create -a \code{trusted()} object in the relevant \code{.Rprofile} and then add a line telling -the agent print it out before doing data analysis: - -\if{html}{\out{
}}\preformatted{The R session has an object called `trusted` that holds this team's trusted -code and context. Before analyzing data, run `print(trusted)`, and use its -calculations where they apply in favor of writing analysis code ad-hoc. -}\if{html}{\out{
}} - -\strong{Claude Code and Codex have no persistent R session of their own.} They can -get one through \href{https://t-kalinowski.github.io/mcp-console/}{MCP Console}. -MCP Console can be configured to run code at startup (which we'll use to -build \code{trusted}), and we'll also edit the console tool's description to tell -the model to print out \code{trusted} before analyzing data. - -To use MCP console, register it for the project, from the project directory: - -\if{html}{\out{
}}\preformatted{uvx mcp-console configure-client claude --directory . -uvx mcp-console configure-client codex --directory . -}\if{html}{\out{
}} - -Then configure MCP Console to build \code{trusted} at startup and to mention it -in its tool description. A configured description replaces the one MCP -Console generates, so start from an export of the resolved configuration: - -\if{html}{\out{
}}\preformatted{uvx mcp-console config resolve --full > .agents/console/config.yaml -}\if{html}{\out{
}} - -In that file, set the startup code, and add the same sentence to the end of -the exported description: - -\if{html}{\out{
}}\preformatted{startup: - language: r - code: | - trusted <- acme.metrics::trusted() - .console$sql_connection(trusted$connection()) -tool: - description: |- - # ...the exported description, unchanged... - - The R session has an object called `trusted` that holds this team's - trsuted code and context. Before analyzing data, run - `print(trusted)`, and use its calculations where they apply in favor of - writing analysis code ad-hoc. -}\if{html}{\out{
}} - -Claude Code shows the tool description only once the agent loads the tool, -so add the sentence to the end of \code{tool.instructions} as well. -} - -\examples{ -\dontrun{ -tr <- trusted( - data_source(warehouse = con), - semantic_layer = sem, - context_layer = context_layer(files = "notes.md") -) -tr -tr$search("revenue by region") -tr$measure("revenue_by_region", arguments = list(region = "West")) - -# The same object backs an agent: -agent <- commons(ellmer::chat_anthropic(), tr) -} -} diff --git a/pkg-r/tests/testthat/_snaps/agent.md b/pkg-r/tests/testthat/_snaps/agent.md new file mode 100644 index 000000000..0930febea --- /dev/null +++ b/pkg-r/tests/testthat/_snaps/agent.md @@ -0,0 +1,25 @@ +# agent() validates its inputs + + Code + agent(client = "not a chat", commons = cm) + Condition + Error in `agent()`: + ! `client` must be an , e.g. from `ellmer::chat_anthropic()`. + +--- + + Code + agent(test_client(), commons = test_source()) + Condition + Error in `agent()`: + ! `commons` must be a `commons()` object, not a object. + i Gather data sources and layers with `commons(data_sources = ...)`. + +--- + + Code + agent(test_client(), commons = cm, log = TRUE, share_with = 1) + Condition + Error in `agent()`: + ! `share_with` must be a character vector of Connect usernames. + diff --git a/pkg-r/tests/testthat/_snaps/commons.md b/pkg-r/tests/testthat/_snaps/commons.md index f59d344b9..f88ae2e8b 100644 --- a/pkg-r/tests/testthat/_snaps/commons.md +++ b/pkg-r/tests/testthat/_snaps/commons.md @@ -1,49 +1,50 @@ -# commons() validates its inputs +# commons() requires named arguments Code - commons(client = "not a chat", data_sources = test_source()) + commons(test_source()) Condition Error in `commons()`: - ! `client` must be an , e.g. from `ellmer::chat_anthropic()`. + ! `...` must be empty. + x Problematic argument: + * ..1 = test_source() + i Did you forget to name an argument? --- Code - commons(client = test_client(), data_sources = "not a source") + commons() Condition Error in `commons()`: - ! `data_sources` must be a `data_source()` or a named list of them. + ! `data_sources` is absent but must be supplied. ---- +# commons() validates its inputs Code - commons(client = test_client(), data_sources = test_source(), context_layer = "not context") + commons(data_sources = "not a source") Condition Error in `commons()`: - ! `context_layer` must be a `context_layer()` or `NULL`. + ! `data_sources` must be a `data_source()` or a named list of them. --- Code - commons(client = test_client(), data_sources = test_source(), semantic_layer = list()) + commons(data_sources = test_source(), context_layer = "not context") Condition Error in `commons()`: - ! `semantic_layer` must be a `semantic_layer()`. + ! `context_layer` must be a `context_layer()` or `NULL`. --- Code - commons(client = test_client(), data_sources = test_source(), log = TRUE, - share_with = 1) + commons(data_sources = test_source(), semantic_layer = list()) Condition Error in `commons()`: - ! `share_with` must be a character vector of Connect usernames. + ! `semantic_layer` must be a `semantic_layer()`. # commons() errors on injection parameters matching no name Code - commons(client = test_client(), data_sources = list(sales_db = test_source()), - semantic_layer = layer) + commons(data_sources = list(sales_db = test_source()), semantic_layer = layer) Condition Error in `commons()`: ! Measure "region_revenue" has undocumented argument `warehouse` matching no data source. @@ -52,9 +53,133 @@ --- Code - commons(client = test_client(), data_sources = test_source(), semantic_layer = layer) + commons(data_sources = test_source(), semantic_layer = layer) Condition Error in `commons()`: ! Measure "region_revenue" has undocumented argument `warehouse` matching no data source. i `data_sources` has no named sources. +# commons() still builds an agent when called the old way + + Code + old <- commons(test_client(), list(sales_db = test_source()), layer, mode = "trusted only") + Condition + Warning: + The `client` argument of `commons()` is deprecated as of commons 0.2.0. + i Please use `agent()` instead. + i Build the commons with `commons(data_sources = ...)`, then pass it to `agent(client, commons = commons)`. + +--- + + Code + commons(client = "not a chat", data_sources = test_source()) + Condition + Warning: + The `client` argument of `commons()` is deprecated as of commons 0.2.0. + i Please use `agent()` instead. + i Build the commons with `commons(data_sources = ...)`, then pass it to `agent(client, commons = commons)`. + Error in `agent()`: + ! `client` must be an , e.g. from `ellmer::chat_anthropic()`. + +--- + + Code + commons(test_client(), test_source(), NULL, NULL, "prompt") + Condition + Warning: + The `client` argument of `commons()` is deprecated as of commons 0.2.0. + i Please use `agent()` instead. + i Build the commons with `commons(data_sources = ...)`, then pass it to `agent(client, commons = commons)`. + Error in `commons()`: + ! `...` must be empty. + x Problematic argument: + * ..1 = "prompt" + i Did you forget to name an argument? + +# $search() names the commons object's methods + + Code + tr$search("big revenue region orders") + Output + ### {{big_revenue}} --- metric on table `sales` + Revenue over big EMEA orders. + Selected SQL(duckdb): `(sum(CASE WHEN ("region" = 'EMEA') AND "revenue" > 600 THEN "revenue" ELSE 0 END))`. + Translation notes: DuckDB compares a NaN as equal to itself and greater than every number, where data-dict answers false; a row holding one passes here and is reported there. DuckDB sums integers at 128 bits, so a total data-dict reports as an overflow (D09) may succeed. + Query with `$metrics("big_revenue")`. + Filters and derived definitions on this table: {{emea}} (filter), {{region_band}} (derived). + + ### order_count + Count orders, optionally filtered by region and a revenue ceiling. + + arguments: + - region (array of {Americas, APAC, EMEA}, optional) + - revenue_under (number, optional) + Run with `$measure("order_count")`. + + ### {{emea}} --- filter on table `sales` + EMEA rows only. + Selected SQL(duckdb): `("region" = 'EMEA')`. + Use as a `$metrics()` filter or dimension. + + ### {{region_band}} --- derived on table `sales` + Coarse region grouping. + Selected SQL(duckdb): `(CASE WHEN ("region" = 'EMEA') THEN 'east' ELSE 'west' END)`. + Use as a `$metrics()` dimension. + +# $metrics() accepts `where` as a string or unnamed triples + + Code + tr$metrics("big_revenue", where = "region IN ('EMEA', 'APAC')") + Condition + Error in `tr$metrics()`: + ! `where` must be simple conditions on documented columns. + i Pass a string like `"project = 'dplyr' AND date >= '2026-06-01'"`, or a list of `c(column, op, value)` triples. + i Operators: `=`, `!=`, `<`, `<=`, `>`, and `>=`, joined by `AND`. + Code + tr$metrics("big_revenue", where = list(c("region", "EMEA"))) + Condition + Error in `tr$metrics()`: + ! `where` must be simple conditions on documented columns. + i Pass a string like `"project = 'dplyr' AND date >= '2026-06-01'"`, or a list of `c(column, op, value)` triples. + i Operators: `=`, `!=`, `<`, `<=`, `>`, and `>=`, joined by `AND`. + +# commons() prints its contents and method signatures + + Code + tr + Output + + Sources: sales_db. Tables: sales. + Measures: + - order_count: Count orders, optionally filtered by region and a revenue ceiling. + Governed metrics: 3. Find them with `$search()`. + Methods: + $search(query) + $measure(name, arguments = list()) + $metrics(metrics, dimensions = NULL, filters = NULL, where = NULL, arguments = list(), source = NULL) + $context(query) + $describe(table, source = NULL) + $connection(source = NULL) + This object provides access to trusted code and context. Before writing + your own analysis code or SQL, look for existing code with `$search()` + and run it with `$measure()` or `$metrics()`. When none fits, check + `$context("your query")` for guidance on the approach. Say in your + answer which numbers came from trusted calculations and which didn't. + +# commons() lists only the calculation methods it can run + + Code + commons(data_sources = list(sales_db = test_source())) + Output + + Sources: sales_db. Tables: sales. + Methods: + $search(query) + $context(query) + $describe(table, source = NULL) + $connection(source = NULL) + This object provides access to trusted code and context. Before writing + your own analysis code or SQL, check `$context("your query")` for + guidance on the approach. Say in your answer which numbers came from + trusted calculations and which didn't. + diff --git a/pkg-r/tests/testthat/_snaps/trusted-only.md b/pkg-r/tests/testthat/_snaps/trusted-only.md index 243aeaa69..c2183e8fa 100644 --- a/pkg-r/tests/testthat/_snaps/trusted-only.md +++ b/pkg-r/tests/testthat/_snaps/trusted-only.md @@ -12,7 +12,7 @@ Code trusted_agent(semantic_layer = semantic_layer(count_measure_tool()), network = "full") Condition - Error in `commons()`: + Error in `agent()`: ! `network = "full"` can't be combined with `mode = "trusted only"`, which has no R session. # call_metrics errors don't point at fallback tools diff --git a/pkg-r/tests/testthat/_snaps/trusted.md b/pkg-r/tests/testthat/_snaps/trusted.md deleted file mode 100644 index f094aebbd..000000000 --- a/pkg-r/tests/testthat/_snaps/trusted.md +++ /dev/null @@ -1,95 +0,0 @@ -# commons() builds an agent around a trusted() object - - Code - commons(test_client(), tr, semantic_layer = semantic_layer()) - Condition - Error in `commons()`: - ! Pass `semantic_layer` and `context_layer` to `trusted()`, not `commons()`, when `data_sources` is a `trusted()` object. - -# $search() names the trusted object's methods - - Code - tr$search("big revenue region orders") - Output - ### {{big_revenue}} --- metric on table `sales` - Revenue over big EMEA orders. - Selected SQL(duckdb): `(sum(CASE WHEN ("region" = 'EMEA') AND "revenue" > 600 THEN "revenue" ELSE 0 END))`. - Translation notes: DuckDB compares a NaN as equal to itself and greater than every number, where data-dict answers false; a row holding one passes here and is reported there. DuckDB sums integers at 128 bits, so a total data-dict reports as an overflow (D09) may succeed. - Query with `$metrics("big_revenue")`. - Filters and derived definitions on this table: {{emea}} (filter), {{region_band}} (derived). - - ### order_count - Count orders, optionally filtered by region and a revenue ceiling. - - arguments: - - region (array of {Americas, APAC, EMEA}, optional) - - revenue_under (number, optional) - Run with `$measure("order_count")`. - - ### {{emea}} --- filter on table `sales` - EMEA rows only. - Selected SQL(duckdb): `("region" = 'EMEA')`. - Use as a `$metrics()` filter or dimension. - - ### {{region_band}} --- derived on table `sales` - Coarse region grouping. - Selected SQL(duckdb): `(CASE WHEN ("region" = 'EMEA') THEN 'east' ELSE 'west' END)`. - Use as a `$metrics()` dimension. - -# $metrics() accepts `where` as a string or unnamed triples - - Code - tr$metrics("big_revenue", where = "region IN ('EMEA', 'APAC')") - Condition - Error in `tr$metrics()`: - ! `where` must be simple conditions on documented columns. - i Pass a string like `"project = 'dplyr' AND date >= '2026-06-01'"`, or a list of `c(column, op, value)` triples. - i Operators: `=`, `!=`, `<`, `<=`, `>`, and `>=`, joined by `AND`. - Code - tr$metrics("big_revenue", where = list(c("region", "EMEA"))) - Condition - Error in `tr$metrics()`: - ! `where` must be simple conditions on documented columns. - i Pass a string like `"project = 'dplyr' AND date >= '2026-06-01'"`, or a list of `c(column, op, value)` triples. - i Operators: `=`, `!=`, `<`, `<=`, `>`, and `>=`, joined by `AND`. - -# trusted() prints its contents and method signatures - - Code - tr - Output - - Sources: sales_db. Tables: sales. - Measures: - - order_count: Count orders, optionally filtered by region and a revenue ceiling. - Governed metrics: 3. Find them with `$search()`. - Methods: - $search(query) - $measure(name, arguments = list()) - $metrics(metrics, dimensions = NULL, filters = NULL, where = NULL, arguments = list(), source = NULL) - $context(query) - $describe(table, source = NULL) - $connection(source = NULL) - This object provides access to trusted code and context. Before writing - your own analysis code or SQL, look for existing code with `$search()` - and run it with `$measure()` or `$metrics()`. When none fits, check - `$context("your query")` for guidance on the approach. Say in your - answer which numbers came from trusted calculations and which didn't. - -# trusted() lists only the calculation methods it can run - - Code - trusted(list(sales_db = test_source())) - Output - - Sources: sales_db. Tables: sales. - Methods: - $search(query) - $context(query) - $describe(table, source = NULL) - $connection(source = NULL) - This object provides access to trusted code and context. Before writing - your own analysis code or SQL, check `$context("your query")` for - guidance on the approach. Say in your answer which numbers came from - trusted calculations and which didn't. - diff --git a/pkg-r/tests/testthat/apps/citation-stream/app.R b/pkg-r/tests/testthat/apps/citation-stream/app.R index b7d2dd6c5..919a72902 100644 --- a/pkg-r/tests/testthat/apps/citation-stream/app.R +++ b/pkg-r/tests/testthat/apps/citation-stream/app.R @@ -82,7 +82,7 @@ client <- ellmer::Chat[["new"]]( ) stopifnot(S7::S7_inherits(client$get_model_object(), ellmer::Model)) source <- data_source(fixture = data.frame(value = 1)) -agent <- commons(client, data_sources = list(fixture = source)) +agent <- agent(client, commons = commons(data_sources = list(fixture = source))) agent$.__enclos_env__$private$corpus <- list( list(label = "documentation", kind = "prose", text = quote_one), list(label = "sales table", kind = "schema", text = quote_two) diff --git a/pkg-r/tests/testthat/helper-commons.R b/pkg-r/tests/testthat/helper-commons.R index 674867ce0..bd2f02dc5 100644 --- a/pkg-r/tests/testthat/helper-commons.R +++ b/pkg-r/tests/testthat/helper-commons.R @@ -80,11 +80,13 @@ test_agent <- function( ) { # On Windows, this would error without setting this option. withr::local_options(commons.allow_unsafe_fallback = TRUE) - commons( + agent( test_client(), - data_sources = data_sources, - context_layer = context_layer, - semantic_layer = semantic_layer, + commons = commons( + data_sources = data_sources, + context_layer = context_layer, + semantic_layer = semantic_layer + ), log = log, ... ) diff --git a/pkg-r/tests/testthat/test-agent.R b/pkg-r/tests/testthat/test-agent.R new file mode 100644 index 000000000..415f66819 --- /dev/null +++ b/pkg-r/tests/testthat/test-agent.R @@ -0,0 +1,811 @@ +test_that("agent() registers only the tools the agent's composition earns", { + agent <- test_agent() + + expect_s3_class(agent, "Chat") + # No measures: nothing about the agent's surface should imply them. + expect_setequal( + vapply(agent$get_tools(), tool_name, character(1)), + c( + "search_context", + "describe_table", + "run_sql", + "run_r", + "load_skill" + ) + ) + expect_no_match(agent$get_system_prompt(), "search_pool") + + with_measures <- test_agent( + semantic_layer = semantic_layer(count_measure_tool()) + ) + expect_setequal( + vapply(with_measures$get_tools(), tool_name, character(1)), + c( + "search_pool", + "call_measure", + "search_context", + "describe_table", + "run_sql", + "run_r", + "load_skill" + ) + ) +}) + +test_that("agent() configures run_r network access", { + restricted <- agent_tool(test_agent(), "run_r") + full <- agent_tool(test_agent(network = "full"), "run_r") + + expect_match(tool_description(restricted), "no network access") + expect_no_match(tool_description(full), "network access") + expect_false(S7::prop(restricted, "annotations")$open_world_hint) + expect_true(S7::prop(full, "annotations")$open_world_hint) +}) + +test_that("run_r describes both protection modes as sandboxed", { + sandboxed <- Commons$new( + client = test_client(), + commons = commons(data_sources = list(sales_db = test_source())), + protection = "sandbox" + ) + guarded <- Commons$new( + client = test_client(), + commons = commons(data_sources = list(sales_db = test_source())), + protection = "guardrails" + ) + sandboxed_description <- tool_description(agent_tool(sandboxed, "run_r")) + guarded_description <- tool_description(agent_tool(guarded, "run_r")) + + expect_identical(guarded_description, sandboxed_description) +}) + +test_that("search_context uses a completed title without a context layer", { + result <- agent_tool(test_agent(), "search_context")("anything") + + expect_equal(result@extra$display$title, "Searched context") +}) + +test_that("the system prompt includes tables and the date", { + agent <- test_agent( + semantic_layer = semantic_layer( + measure( + "order_count", + "Count of orders.", + function() nrow(test_sales()), + arguments = list() + ) + ) + ) + prompt <- agent$get_system_prompt() + + expect_match(prompt, "sales") + expect_no_match(prompt, "order_count") + expect_match(prompt, format(Sys.Date(), "%Y-%m-%d"), fixed = TRUE) +}) + +test_that("instructions are appended to the packaged system prompt", { + instructions <- "Use the organization's fiscal-year conventions." + agent <- test_agent(instructions = instructions) + prompt <- agent$get_system_prompt() + + expect_match( + prompt, + paste("## Additional instructions", instructions, sep = "\n\n"), + fixed = TRUE + ) + expect_true(endsWith(prompt, instructions)) +}) + +test_that("instructions can be read from a file", { + path <- withr::local_tempfile(fileext = ".md") + writeLines("Prefer fiscal-year comparisons.", path) + prompt <- test_agent(instructions = path)$get_system_prompt() + + expect_true(endsWith(prompt, "Prefer fiscal-year comparisons.")) +}) + +test_that("a system prompt already set on the client warns", { + withr::local_options(commons.allow_unsafe_fallback = TRUE) + client <- test_client() + client$set_system_prompt("You are a pirate.") + + expect_warning( + agent( + client, + commons = commons(data_sources = list(sales_db = test_source())) + ), + "commons builds its own" + ) +}) + +test_that("instructions are validated", { + expect_error( + test_agent(instructions = c("a", "b")), + "single string" + ) + + expect_error( + test_agent(instructions = "missing-instructions.md"), + "does not exist" + ) +}) + +test_that("the system prompt includes schema-qualified table labels", { + withr::local_options(commons.allow_unsafe_fallback = TRUE) + con <- DBI::dbConnect(duckdb::duckdb()) + withr::defer(DBI::dbDisconnect(con, shutdown = TRUE)) + DBI::dbExecute(con, "CREATE SCHEMA crm") + DBI::dbExecute( + con, + "CREATE TABLE crm.sales (order_id VARCHAR, revenue DOUBLE)" + ) + + agent <- agent( + test_client(), + commons = commons(data_sources = data_source(con, tables = "crm.sales")) + ) + + expect_match(agent$get_system_prompt(), "- crm.sales", fixed = TRUE) +}) + +test_that("semantic_layer stores measures off the provider tool list", { + agent <- test_agent( + semantic_layer = semantic_layer( + measure( + "order_count", + "Count of orders.", + function() nrow(test_sales()), + arguments = list() + ) + ) + ) + + provider_tools <- vapply(agent$get_tools(), tool_name, character(1)) + expect_false("order_count" %in% provider_tools) +}) + +test_that("agent() accepts an empty semantic layer", { + agent <- test_agent(semantic_layer = semantic_layer()) + expect_s3_class(agent, "Chat") +}) + +test_that("agent() validates its inputs", { + withr::local_options(commons.allow_unsafe_fallback = TRUE) + cm <- commons(data_sources = test_source()) + expect_error(test_agent(network = "partial"), "network") + expect_error(agent(test_client(), cm, "prompt"), "must be empty") + expect_snapshot(agent(client = "not a chat", commons = cm), error = TRUE) + expect_snapshot(agent(test_client(), commons = test_source()), error = TRUE) + expect_snapshot( + agent(test_client(), commons = cm, log = TRUE, share_with = 1), + error = TRUE + ) +}) + +test_that("SQL tools gain a source argument only with multiple sources", { + single <- test_agent() + expect_named(tool_properties(agent_tool(single, "run_sql")), "sql") + expect_named(tool_properties(agent_tool(single, "describe_table")), "table") + + multi <- test_agent( + data_sources = list(sales_db = test_source(), crm = test_source()) + ) + run_sql_props <- tool_properties(agent_tool(multi, "run_sql")) + expect_named(run_sql_props, c("sql", "source")) + expect_equal(type_values(run_sql_props$source), c("sales_db", "crm")) + expect_named( + tool_properties(agent_tool(multi, "describe_table")), + c("table", "source") + ) +}) + +test_that("run_sql and describe_table route to the named source", { + agent <- test_agent( + data_sources = list( + a = data_source(orders = data.frame(n = 1L)), + b = data_source(orders = data.frame(n = 2L)) + ) + ) + + run_sql <- agent_tool(agent, "run_sql") + expect_match(run_sql("SELECT n FROM orders", source = "a")@value, "1") + expect_match(run_sql("SELECT n FROM orders", source = "b")@value, "2") + + describe <- agent_tool(agent, "describe_table") + res <- describe("orders", source = "b") + expect_match(res@value, "INTEGER") +}) + +test_that("run_sql delivers one citation reminder per user turn", { + agent <- test_agent() + run_sql <- agent_tool(agent, "run_sql") + + first <- run_sql("SELECT count(*) AS n FROM sales") + second <- run_sql("SELECT count(*) AS n FROM sales") + + expect_match(first@value, "", fixed = TRUE) + expect_no_match(second@value, "", fixed = TRUE) +}) + +test_that("the system prompt groups tables when there are several sources", { + agent <- test_agent( + data_sources = list( + sales_db = test_source(), + crm = data_source(accounts = data.frame(id = 1)) + ) + ) + prompt <- agent$get_system_prompt() + + expect_match(prompt, "## sales_db (duckdb)", fixed = TRUE) + expect_match(prompt, "## crm (duckdb)", fixed = TRUE) + expect_match(prompt, "- accounts", fixed = TRUE) + expect_match(prompt, "Pass the source's name as `source`", fixed = TRUE) + + expect_no_match(test_agent()$get_system_prompt(), "## sales_db", fixed = TRUE) +}) + +test_that("a measure can take multiple sources' connections", { + layer <- semantic_layer( + measure( + "compare_sources", + "Compares the two databases.", + function(a, b) NULL, + arguments = list() + ) + ) + agent <- test_agent( + data_sources = list(a = test_source(), b = test_source()), + semantic_layer = layer + ) + + expect_named( + agent$.__enclos_env__$private$injections$compare_sources, + c("a", "b") + ) +}) + +test_that("measures receive named data source connections by injection", { + layer <- semantic_layer( + measure( + "region_revenue", + "Total revenue for a region.", + function(region, sales_db) { + DBI::dbGetQuery( + sales_db, + sprintf( + "SELECT SUM(revenue) AS revenue FROM sales WHERE region = %s", + DBI::dbQuoteString(sales_db, region) + ) + ) + }, + arguments = list(region = ellmer::type_string("The sales region.")) + ) + ) + agent <- test_agent(semantic_layer = layer) + + registry <- agent$.__enclos_env__$private$registry + injections <- agent$.__enclos_env__$private$injections + + expect_named(injections$region_revenue, "sales_db") + res <- call_measure_tool( + registry, + "region_revenue", + '{"region": "EMEA"}', + injections = injections + ) + expect_match(res@value, "2450") +}) + +test_that("injection parameters are hidden from the model", { + layer <- semantic_layer( + measure( + "region_revenue", + "Total revenue for a region.", + function(region, sales_db) NULL, + arguments = list(region = ellmer::type_string("The sales region.")) + ) + ) + agent <- test_agent(semantic_layer = layer) + registry <- agent$.__enclos_env__$private$registry + + expect_named(tool_properties(registry$region_revenue), "region") + expect_no_match( + search_pool_text(registry, empty_definitions(), "revenue for a region"), + "sales_db" + ) +}) + +test_that("undocumented arguments matching no entry keep their defaults", { + layer <- semantic_layer( + measure( + "order_count", + "Count of orders.", + function(sales_db, limit = 5L) limit, + arguments = list() + ) + ) + agent <- test_agent(semantic_layer = layer) + + injections <- agent$.__enclos_env__$private$injections + expect_named(injections$order_count, "sales_db") + + res <- call_measure_tool( + agent$.__enclos_env__$private$registry, + "order_count", + "{}", + injections = injections + ) + expect_match(res@value, "5") +}) + +test_that("an entry match wins over an undocumented argument's default", { + layer <- semantic_layer( + measure( + "source_class", + "Class of the source object.", + function(sales_db = "unused default") class(sales_db)[[1]], + arguments = list() + ) + ) + agent <- test_agent(semantic_layer = layer) + + res <- call_measure_tool( + agent$.__enclos_env__$private$registry, + "source_class", + "{}", + injections = agent$.__enclos_env__$private$injections + ) + expect_match(res@value, "duckdb_connection") +}) + +test_that("prewarm() builds the context store ahead of the first search", { + # requires duckdb extensions via ragnar (#400) + skip_on_cran() + cache_dir <- withr::local_tempdir() + withr::local_options(commons.context_cache = cache_dir) + path <- withr::local_tempfile(fileext = ".md") + writeLines(c("# Revenue", "", "Revenue means booked revenue."), path) + layer <- context_layer(files = path) + + # test_source() has no dictionary, so the agent augments nothing and shares + # `layer`'s store. + agent <- test_agent(context_layer = layer) + expect_null(context_layer_state(layer)$store) + + agent$prewarm() + expect_false(is.null(context_layer_state(layer)$store)) + expect_length(list.files(file.path(cache_dir, "context")), 1) + expect_match(context_search(layer, "revenue")[[1]], "booked") +}) + +test_that("prewarm() without a context layer is a no-op", { + expect_no_error(test_agent()$prewarm()) +}) + +test_that("prewarm() propagates failures", { + path <- withr::local_tempfile(fileext = ".md") + writeLines(c("# Revenue", "", "Revenue means booked revenue."), path) + agent <- test_agent(context_layer = context_layer(files = path)) + + local_mocked_bindings( + context_store = function(...) stop("index build exploded"), + .package = "commons" + ) + expect_error(agent$prewarm(), "index build exploded") +}) + +test_that("prewarm() records a cache-miss build and its own span", { + # requires duckdb extensions via ragnar (#400) + skip_on_cran() + skip_if_not_installed("otelsdk") + # A fresh cache root guarantees a cold build regardless of test order. + withr::local_options(commons.context_cache = withr::local_tempdir()) + + path <- withr::local_tempfile(fileext = ".md") + writeLines(c("# Revenue", "", "Revenue means booked revenue."), path) + agent <- test_agent(context_layer = context_layer(files = path)) + + recorded <- otelsdk::with_otel_record(agent$prewarm()) + names <- vapply(recorded$traces, `[[`, character(1), "name") + expect_true("commons_context_store_build" %in% names) + + build_span <- recorded$traces[[which(names == "commons_context_store_build")]] + expect_equal(build_span$attributes[["commons.context.n_docs"]], 1L) + + prewarm_span <- recorded$traces[[which(names == "commons_context_prewarm")]] + expect_equal(prewarm_span$attributes[["commons.context.n_docs"]], 1L) + expect_equal(prewarm_span$attributes[["commons.context.cache_hit"]], FALSE) +}) + +test_that("prewarm() records a cache hit without a build span", { + # requires duckdb extensions via ragnar (#400) + skip_on_cran() + skip_if_not_installed("otelsdk") + withr::local_options(commons.context_cache = withr::local_tempdir()) + + path <- withr::local_tempfile(fileext = ".md") + writeLines(c("# Revenue", "", "Revenue means booked revenue."), path) + agent <- test_agent(context_layer = context_layer(files = path)) + agent$prewarm() + + recorded <- otelsdk::with_otel_record(agent$prewarm()) + names <- vapply(recorded$traces, `[[`, character(1), "name") + expect_false("commons_context_store_build" %in% names) + + prewarm_span <- recorded$traces[[which(names == "commons_context_prewarm")]] + expect_equal(prewarm_span$attributes[["commons.context.cache_hit"]], TRUE) +}) + +test_that("prewarm() warms board pins in the background without loading them", { + skip_if_not_installed("pins") + + board <- board_with_pins( + "team-orders" = data.frame(id = 1:3), + "team-reps" = data.frame(rep = c("Ada", "Bo")) + ) + src <- data_source( + board, + tables = c(orders = "team-orders", reps = "team-reps") + ) + agent <- test_agent(data_sources = list(sales_db = src)) + + expect_length(DBI::dbListTables(data_source_state(src)$con), 0) + + agent$prewarm() + p <- data_source_state(src)$pending$process + expect_s3_class(p, "r_process") + withr::defer(p$kill()) + wait_for_prewarm(p) + + # Warming fills the pins cache only; tables still load at first use. + expect_length(DBI::dbListTables(data_source_state(src)$con), 0) + expect_setequal(names(data_source_state(src)$pending$pins), c("orders", "reps")) + source_describe(src, "orders") + expect_equal(DBI::dbListTables(data_source_state(src)$con), "orders") +}) + +test_that("a measure injected a board source loads its pins when it runs", { + skip_if_not_installed("pins") + + board <- board_with_pins("team-orders" = data.frame(id = 1:3)) + src <- data_source(board, tables = c(orders = "team-orders")) + + layer <- semantic_layer( + measure( + "order_count", + "Count of orders.", + function(warehouse) { + DBI::dbGetQuery(warehouse, "SELECT count(*) AS n FROM orders")$n + }, + arguments = list() + ) + ) + agent <- test_agent( + semantic_layer = layer, + data_sources = list(warehouse = src) + ) + expect_length(DBI::dbListTables(data_source_state(src)$con), 0) + + private <- agent$.__enclos_env__$private + res <- call_measure_tool( + private$registry, + "order_count", + "{}", + injections = private$injections, + sources = private$sources + ) + expect_match(res@value, "3") + expect_equal(DBI::dbListTables(data_source_state(src)$con), "orders") +}) + +test_that("agent() records an agent-creation span", { + skip_if_not_installed("otelsdk") + + recorded <- otelsdk::with_otel_record(test_agent()) + names <- vapply(recorded$traces, `[[`, character(1), "name") + span <- recorded$traces[[which(names == "commons_agent_create")]] + expect_equal(span$attributes[["commons.agent.n_data_sources"]], 1L) + expect_equal(span$attributes[["commons.agent.has_context_layer"]], FALSE) +}) + +# Split a real ellmer response inside reserved markup to test chunk invariance. +stream_citations_fixture <- function(agent, raw, split_at) { + skip_if_ellmer_streaming_hooks_unavailable() + final_turn <- ellmer::AssistantTurn( + list(ellmer::ContentText(raw)), + tokens = c(0, 0, 0), + cost = 0 + ) + chunks <- list( + substr(raw, 1, split_at), + substr(raw, split_at + 1, nchar(raw)) + ) + make_response <- function() { + coro::async_generator(function() { + yield(list(text = chunks[[1]])) + yield(list(text = chunks[[2]])) + coro::exhausted() + })() + } + testthat::local_mocked_bindings( + chat_perform = function(...) make_response(), + stream_merge_chunks = function(provider, result, chunk) chunk, + stream_content_with_turns = function(provider, event, completion, turns) { + list(ellmer::ContentText(event$text)) + }, + value_finish_reason = function(provider, result) "stop", + value_turn_with_turns = function( + provider, + model, + result, + has_type = FALSE, + turns = list() + ) { + final_turn + }, + .package = "ellmer" + ) + user_input <- list("What does canopy cover mean?") + sync_promise(coro::async_collect(agent$stream_async(!!!user_input))) +} + +test_that("models needing brevity prompting add one hidden reminder", { + withr::local_options(commons.allow_unsafe_fallback = TRUE) + agent <- agent( + ellmer::chat_anthropic(model = "claude-sonnet-5"), + commons = commons(data_sources = list(sales_db = test_source())) + ) + + stream_citations_fixture(agent, "Answer.", split_at = 3) + + turn <- agent$last_turn("user") + reminders <- vapply( + turn@contents, + S7::S7_inherits, + logical(1), + class = ContentTurnReminder + ) + + expect_equal(sum(reminders), 1) + expect_equal( + shinychat::contents_shinychat(turn), + list("What does canopy cover mean?") + ) +}) + +test_that("restored conversations add one hidden reminder to the next turn", { + agent <- test_agent() + agent$queue_restore_reminder() + + unused_stream <- agent$stream_async("Do not consume this stream.") + expect_s3_class(unused_stream, "coro_generator_instance") + expect_true(agent$.__enclos_env__$private$restore_reminder_pending) + + expect_error(agent$chat("Invalid request.", echo = "invalid")) + expect_true(agent$.__enclos_env__$private$restore_reminder_pending) + + stream_citations_fixture(agent, "First answer.", split_at = 5) + expect_false(agent$.__enclos_env__$private$restore_reminder_pending) + + turn <- agent$last_turn("user") + reminders <- Filter( + function(content) S7::S7_inherits(content, ContentTurnReminder), + turn@contents + ) + expect_length(reminders, 1) + expect_identical(reminders[[1]]@text, restored_conversation_turn_reminder) + expect_equal( + shinychat::contents_shinychat(turn), + list("What does canopy cover mean?") + ) + + stream_citations_fixture(agent, "Second answer.", split_at = 6) + + turn <- agent$last_turn("user") + reminders <- Filter( + function(content) S7::S7_inherits(content, ContentTurnReminder), + turn@contents + ) + expect_length(reminders, 0) +}) + +test_that("replacing restored history clears its queued reminder", { + agent <- test_agent() + agent$queue_restore_reminder() + + agent$set_turns(list()) + + expect_false(agent$.__enclos_env__$private$restore_reminder_pending) +}) + +test_that("stream_async projects citations without touching stored turns", { + path <- withr::local_tempfile(fileext = ".md") + writeLines("Canopy cover is always acre-weighted for reporting.", path) + agent <- test_agent(context_layer = context_layer(files = path)) + + raw <- paste0( + "Answer sentence.\n\n", + "\n\nFollows the weighting rule.\n\n", + "> Canopy cover is always acre-weighted for reporting.\n\n", + "\n\nMore text.\n\n", + 'not from the server\n\n', + "\n\nr\n\n> a fabricated quote goes here\n\n\n\nEnd." + ) + + chunks <- stream_citations_fixture(agent, raw, split_at = 30) + concatenated <- paste(unlist(chunks), collapse = "") + + expect_identical( + concatenated, + paste0(project_citation_text(raw, agent$citation_corpus())$text, "\n") + ) + expect_false(any(grepl("", unlist(chunks)))) + expect_false(any(grepl("spoofed", unlist(chunks), fixed = TRUE))) + + turns <- agent$get_turns() + stored_text <- turns[[length(turns)]]@contents[[1]]@text + expect_identical(stored_text, raw) +}) + +test_that("stream_async preserves structured provider content", { + skip_if_ellmer_streaming_hooks_unavailable() + structured <- ellmer::ContentThinking("provider citation metadata") + final_turn <- ellmer::AssistantTurn( + list( + ellmer::ContentText("Before "), + structured, + ellmer::ContentText("after.") + ), + tokens = c(0, 0, 0), + cost = 0 + ) + make_response <- function() { + coro::async_generator(function() { + for (content in final_turn@contents) { + yield(list(content = content)) + } + coro::exhausted() + })() + } + testthat::local_mocked_bindings( + chat_perform = function(...) make_response(), + stream_merge_chunks = function(provider, result, chunk) chunk, + stream_content_with_turns = function(provider, event, completion, turns) { + list(event$content) + }, + value_finish_reason = function(provider, result) "stop", + value_turn_with_turns = function( + provider, + model, + result, + has_type = FALSE, + turns = list() + ) { + final_turn + }, + .package = "ellmer" + ) + agent <- test_agent() + + chunks <- sync_promise(coro::async_collect( + agent$stream_async("Use provider evidence.", stream = "content") + )) + + expect_s7_class(chunks[[1]], ellmer::ContentText) + expect_identical(chunks[[1]]@text, "Before ") + expect_identical(chunks[[2]], structured) + expect_s7_class(chunks[[3]], ellmer::ContentText) + expect_identical(chunks[[3]]@text, "after.") +}) + +test_that("stream_async records provenance at span creation and completion", { + skip_if_not_installed("otelsdk") + withr::local_envvar( + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = "true" + ) + path <- withr::local_tempfile(fileext = ".md") + writeLines("Canopy cover is always acre-weighted for reporting.", path) + local_mocked_bindings(collect_appended_tags = function(...) "B") + + raw <- paste0( + "Answer sentence.\n\n", + "\n\nFollows the weighting rule.\n\n", + "> Canopy cover is always acre-weighted for reporting.\n\n", + "\n\nMore text.\n\n", + "\n\nr\n\n> a fabricated quote goes here\n\n\n\nEnd." + ) + + recorded <- otelsdk::with_otel_record({ + agent <- test_agent(context_layer = context_layer(files = path), log = TRUE) + stream_citations_fixture(agent, raw, split_at = 30) + }) + + names <- vapply(recorded$traces, `[[`, character(1), "name") + span <- recorded$traces[[which(names == "commons_conversation_turn")]] + provenance_span <- recorded$traces[[which(names == "commons_provenance")]] + expect_identical(span$attributes[["commons.provenance.tag"]], "B") + expect_identical( + provenance_span$attributes[["commons.provenance.tag"]], + "B" + ) + expect_identical(provenance_span$parent, span$span_id) + + candidates <- jsonlite::fromJSON( + provenance_span$attributes[["commons.citation.candidates"]], + simplifyVector = FALSE + ) + + expect_equal( + candidates, + list( + list( + quote = "Canopy cover is always acre-weighted for reporting.", + status = "accepted", + label = "documentation", + kind = "prose" + ), + list( + quote = "a fabricated quote goes here", + status = "rejected" + ) + ) + ) +}) + +test_that("a trusted-only agent streams no provenance or citation markers", { + skip_if_not_installed("otelsdk") + withr::local_envvar( + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = "true" + ) + path <- withr::local_tempfile(fileext = ".md") + writeLines("Canopy cover is always acre-weighted for reporting.", path) + local_mocked_bindings(collect_appended_tags = function(...) "A") + + raw <- paste0( + "Answer sentence.\n\n", + "\n\nFollows the weighting rule.\n\n", + "> Canopy cover is always acre-weighted for reporting.\n\n", + "\n\nEnd." + ) + + recorded <- otelsdk::with_otel_record({ + agent <- test_agent( + context_layer = context_layer(files = path), + semantic_layer = semantic_layer(count_measure_tool()), + mode = "trusted only", + log = TRUE + ) + expect_true( + match_citation( + "Canopy cover is always acre-weighted for reporting.", + agent$citation_corpus() + )$kind == "prose" + ) + agent$queue_restore_reminder() + first <- stream_citations_fixture(agent, raw, split_at = 30) + # A second user turn exercises add_turn() without a citation tracker. + second <- stream_citations_fixture(agent, raw, split_at = 12) + }) + + for (chunks in list(first, second)) { + text <- paste(unlist(chunks), collapse = "") + expect_no_match(text, "", fixed = TRUE) - expect_no_match(second@value, "", fixed = TRUE) -}) - -test_that("the system prompt groups tables when there are several sources", { - agent <- test_agent( - data_sources = list( - sales_db = test_source(), - crm = data_source(accounts = data.frame(id = 1)) - ) - ) - prompt <- agent$get_system_prompt() - - expect_match(prompt, "## sales_db (duckdb)", fixed = TRUE) - expect_match(prompt, "## crm (duckdb)", fixed = TRUE) - expect_match(prompt, "- accounts", fixed = TRUE) - expect_match(prompt, "Pass the source's name as `source`", fixed = TRUE) - - expect_no_match(test_agent()$get_system_prompt(), "## sales_db", fixed = TRUE) -}) - -test_that("a measure can take multiple sources' connections", { - layer <- semantic_layer( - measure( - "compare_sources", - "Compares the two databases.", - function(a, b) NULL, - arguments = list() - ) - ) - agent <- test_agent( - data_sources = list(a = test_source(), b = test_source()), - semantic_layer = layer - ) - - expect_named( - agent$.__enclos_env__$private$injections$compare_sources, - c("a", "b") - ) -}) - -test_that("measures receive named data source connections by injection", { - layer <- semantic_layer( - measure( - "region_revenue", - "Total revenue for a region.", - function(region, sales_db) { - DBI::dbGetQuery( - sales_db, - sprintf( - "SELECT SUM(revenue) AS revenue FROM sales WHERE region = %s", - DBI::dbQuoteString(sales_db, region) - ) - ) - }, - arguments = list(region = ellmer::type_string("The sales region.")) - ) - ) - agent <- test_agent(semantic_layer = layer) - - registry <- agent$.__enclos_env__$private$registry - injections <- agent$.__enclos_env__$private$injections - - expect_named(injections$region_revenue, "sales_db") - res <- call_measure_tool( - registry, - "region_revenue", - '{"region": "EMEA"}', - injections = injections - ) - expect_match(res@value, "2450") -}) - -test_that("injection parameters are hidden from the model", { - layer <- semantic_layer( - measure( - "region_revenue", - "Total revenue for a region.", - function(region, sales_db) NULL, - arguments = list(region = ellmer::type_string("The sales region.")) - ) - ) - agent <- test_agent(semantic_layer = layer) - registry <- agent$.__enclos_env__$private$registry - - expect_named(tool_properties(registry$region_revenue), "region") - expect_no_match( - search_pool_text(registry, empty_definitions(), "revenue for a region"), - "sales_db" - ) -}) - -test_that("undocumented arguments matching no entry keep their defaults", { - layer <- semantic_layer( - measure( - "order_count", - "Count of orders.", - function(sales_db, limit = 5L) limit, - arguments = list() - ) - ) - agent <- test_agent(semantic_layer = layer) - - injections <- agent$.__enclos_env__$private$injections - expect_named(injections$order_count, "sales_db") - - res <- call_measure_tool( - agent$.__enclos_env__$private$registry, - "order_count", - "{}", - injections = injections - ) - expect_match(res@value, "5") -}) - -test_that("an entry match wins over an undocumented argument's default", { - layer <- semantic_layer( - measure( - "source_class", - "Class of the source object.", - function(sales_db = "unused default") class(sales_db)[[1]], - arguments = list() - ) - ) - agent <- test_agent(semantic_layer = layer) - - res <- call_measure_tool( - agent$.__enclos_env__$private$registry, - "source_class", - "{}", - injections = agent$.__enclos_env__$private$injections - ) - expect_match(res@value, "duckdb_connection") -}) - test_that("commons() errors on injection parameters matching no name", { withr::local_options(commons.allow_unsafe_fallback = TRUE) layer <- semantic_layer( @@ -397,471 +46,103 @@ test_that("commons() errors on injection parameters matching no name", { expect_snapshot( commons( - client = test_client(), data_sources = list(sales_db = test_source()), semantic_layer = layer ), error = TRUE ) expect_snapshot( - commons( - client = test_client(), - data_sources = test_source(), - semantic_layer = layer - ), + commons(data_sources = test_source(), semantic_layer = layer), error = TRUE ) }) - -test_that("prewarm() builds the context store ahead of the first search", { - # requires duckdb extensions via ragnar (#400) - skip_on_cran() - cache_dir <- withr::local_tempdir() - withr::local_options(commons.context_cache = cache_dir) - path <- withr::local_tempfile(fileext = ".md") - writeLines(c("# Revenue", "", "Revenue means booked revenue."), path) - layer <- context_layer(files = path) - - # test_source() has no dictionary, so the agent augments nothing and shares - # `layer`'s store. - agent <- test_agent(context_layer = layer) - expect_null(context_layer_state(layer)$store) - - agent$prewarm() - expect_false(is.null(context_layer_state(layer)$store)) - expect_length(list.files(file.path(cache_dir, "context")), 1) - expect_match(context_search(layer, "revenue")[[1]], "booked") -}) - -test_that("prewarm() without a context layer is a no-op", { - expect_no_error(test_agent()$prewarm()) -}) - -test_that("prewarm() propagates failures", { - path <- withr::local_tempfile(fileext = ".md") - writeLines(c("# Revenue", "", "Revenue means booked revenue."), path) - agent <- test_agent(context_layer = context_layer(files = path)) - - local_mocked_bindings( - context_store = function(...) stop("index build exploded"), - .package = "commons" - ) - expect_error(agent$prewarm(), "index build exploded") -}) - -test_that("prewarm() records a cache-miss build and its own span", { - # requires duckdb extensions via ragnar (#400) - skip_on_cran() - skip_if_not_installed("otelsdk") - # A fresh cache root guarantees a cold build regardless of test order. - withr::local_options(commons.context_cache = withr::local_tempdir()) - - path <- withr::local_tempfile(fileext = ".md") - writeLines(c("# Revenue", "", "Revenue means booked revenue."), path) - agent <- test_agent(context_layer = context_layer(files = path)) - - recorded <- otelsdk::with_otel_record(agent$prewarm()) - names <- vapply(recorded$traces, `[[`, character(1), "name") - expect_true("commons_context_store_build" %in% names) - - build_span <- recorded$traces[[which(names == "commons_context_store_build")]] - expect_equal(build_span$attributes[["commons.context.n_docs"]], 1L) - - prewarm_span <- recorded$traces[[which(names == "commons_context_prewarm")]] - expect_equal(prewarm_span$attributes[["commons.context.n_docs"]], 1L) - expect_equal(prewarm_span$attributes[["commons.context.cache_hit"]], FALSE) -}) - -test_that("prewarm() records a cache hit without a build span", { - # requires duckdb extensions via ragnar (#400) - skip_on_cran() - skip_if_not_installed("otelsdk") - withr::local_options(commons.context_cache = withr::local_tempdir()) - - path <- withr::local_tempfile(fileext = ".md") - writeLines(c("# Revenue", "", "Revenue means booked revenue."), path) - agent <- test_agent(context_layer = context_layer(files = path)) - agent$prewarm() - - recorded <- otelsdk::with_otel_record(agent$prewarm()) - names <- vapply(recorded$traces, `[[`, character(1), "name") - expect_false("commons_context_store_build" %in% names) - - prewarm_span <- recorded$traces[[which(names == "commons_context_prewarm")]] - expect_equal(prewarm_span$attributes[["commons.context.cache_hit"]], TRUE) -}) - -test_that("prewarm() warms board pins in the background without loading them", { - skip_if_not_installed("pins") - - board <- board_with_pins( - "team-orders" = data.frame(id = 1:3), - "team-reps" = data.frame(rep = c("Ada", "Bo")) +test_that("commons() still builds an agent when called the old way", { + withr::local_options( + commons.allow_unsafe_fallback = TRUE, + lifecycle_verbosity = "warning" ) - src <- data_source( - board, - tables = c(orders = "team-orders", reps = "team-reps") - ) - agent <- test_agent(data_sources = list(sales_db = src)) - - expect_length(DBI::dbListTables(data_source_state(src)$con), 0) - - agent$prewarm() - p <- data_source_state(src)$pending$process - expect_s3_class(p, "r_process") - withr::defer(p$kill()) - wait_for_prewarm(p) - - # Warming fills the pins cache only; tables still load at first use. - expect_length(DBI::dbListTables(data_source_state(src)$con), 0) - expect_setequal(names(data_source_state(src)$pending$pins), c("orders", "reps")) - source_describe(src, "orders") - expect_equal(DBI::dbListTables(data_source_state(src)$con), "orders") -}) - -test_that("a measure injected a board source loads its pins when it runs", { - skip_if_not_installed("pins") + layer <- semantic_layer(count_measure_tool()) - board <- board_with_pins("team-orders" = data.frame(id = 1:3)) - src <- data_source(board, tables = c(orders = "team-orders")) - - layer <- semantic_layer( - measure( - "order_count", - "Count of orders.", - function(warehouse) { - DBI::dbGetQuery(warehouse, "SELECT count(*) AS n FROM orders")$n - }, - arguments = list() + expect_snapshot( + old <- commons( + test_client(), + list(sales_db = test_source()), + layer, + mode = "trusted only" ) ) - agent <- test_agent( - semantic_layer = layer, - data_sources = list(warehouse = src) - ) - expect_length(DBI::dbListTables(data_source_state(src)$con), 0) - - private <- agent$.__enclos_env__$private - res <- call_measure_tool( - private$registry, - "order_count", - "{}", - injections = private$injections, - sources = private$sources - ) - expect_match(res@value, "3") - expect_equal(DBI::dbListTables(data_source_state(src)$con), "orders") -}) + expect_s3_class(old, "Commons") + expect_s3_class(old$commons, "commons") + tools <- vapply(old$get_tools(), tool_name, character(1)) + expect_true("call_measure" %in% tools) + expect_false("run_sql" %in% tools) -test_that("commons() records an agent-creation span", { - skip_if_not_installed("otelsdk") - - recorded <- otelsdk::with_otel_record(test_agent()) - names <- vapply(recorded$traces, `[[`, character(1), "name") - span <- recorded$traces[[which(names == "commons_agent_create")]] - expect_equal(span$attributes[["commons.agent.n_data_sources"]], 1L) - expect_equal(span$attributes[["commons.agent.has_context_layer"]], FALSE) -}) - -# Split a real ellmer response inside reserved markup to test chunk invariance. -stream_citations_fixture <- function(agent, raw, split_at) { - skip_if_ellmer_streaming_hooks_unavailable() - final_turn <- ellmer::AssistantTurn( - list(ellmer::ContentText(raw)), - tokens = c(0, 0, 0), - cost = 0 - ) - chunks <- list( - substr(raw, 1, split_at), - substr(raw, split_at + 1, nchar(raw)) - ) - make_response <- function() { - coro::async_generator(function() { - yield(list(text = chunks[[1]])) - yield(list(text = chunks[[2]])) - coro::exhausted() - })() - } - testthat::local_mocked_bindings( - chat_perform = function(...) make_response(), - stream_merge_chunks = function(provider, result, chunk) chunk, - stream_content_with_turns = function(provider, event, completion, turns) { - list(ellmer::ContentText(event$text)) - }, - value_finish_reason = function(provider, result) "stop", - value_turn_with_turns = function( - provider, - model, - result, - has_type = FALSE, - turns = list() - ) { - final_turn - }, - .package = "ellmer" - ) - user_input <- list("What does canopy cover mean?") - sync_promise(coro::async_collect(agent$stream_async(!!!user_input))) -} - -test_that("models needing brevity prompting add one hidden reminder", { - withr::local_options(commons.allow_unsafe_fallback = TRUE) - agent <- commons( - ellmer::chat_anthropic(model = "claude-sonnet-5"), + named <- suppressWarnings(commons( + client = test_client(), data_sources = list(sales_db = test_source()) + )) + expect_s3_class(named, "Commons") + expect_snapshot( + commons(client = "not a chat", data_sources = test_source()), + error = TRUE ) - - stream_citations_fixture(agent, "Answer.", split_at = 3) - - turn <- agent$last_turn("user") - reminders <- vapply( - turn@contents, - S7::S7_inherits, - logical(1), - class = ContentTurnReminder - ) - - expect_equal(sum(reminders), 1) - expect_equal( - shinychat::contents_shinychat(turn), - list("What does canopy cover mean?") - ) -}) - -test_that("restored conversations add one hidden reminder to the next turn", { - agent <- test_agent() - agent$queue_restore_reminder() - - unused_stream <- agent$stream_async("Do not consume this stream.") - expect_s3_class(unused_stream, "coro_generator_instance") - expect_true(agent$.__enclos_env__$private$restore_reminder_pending) - - expect_error(agent$chat("Invalid request.", echo = "invalid")) - expect_true(agent$.__enclos_env__$private$restore_reminder_pending) - - stream_citations_fixture(agent, "First answer.", split_at = 5) - expect_false(agent$.__enclos_env__$private$restore_reminder_pending) - - turn <- agent$last_turn("user") - reminders <- Filter( - function(content) S7::S7_inherits(content, ContentTurnReminder), - turn@contents - ) - expect_length(reminders, 1) - expect_identical(reminders[[1]]@text, restored_conversation_turn_reminder) - expect_equal( - shinychat::contents_shinychat(turn), - list("What does canopy cover mean?") - ) - - stream_citations_fixture(agent, "Second answer.", split_at = 6) - - turn <- agent$last_turn("user") - reminders <- Filter( - function(content) S7::S7_inherits(content, ContentTurnReminder), - turn@contents + expect_snapshot( + commons(test_client(), test_source(), NULL, NULL, "prompt"), + error = TRUE ) - expect_length(reminders, 0) }) -test_that("replacing restored history clears its queued reminder", { - agent <- test_agent() - agent$queue_restore_reminder() - - agent$set_turns(list()) - - expect_false(agent$.__enclos_env__$private$restore_reminder_pending) -}) - -test_that("stream_async projects citations without touching stored turns", { - path <- withr::local_tempfile(fileext = ".md") - writeLines("Canopy cover is always acre-weighted for reporting.", path) - agent <- test_agent(context_layer = context_layer(files = path)) - - raw <- paste0( - "Answer sentence.\n\n", - "\n\nFollows the weighting rule.\n\n", - "> Canopy cover is always acre-weighted for reporting.\n\n", - "\n\nMore text.\n\n", - 'not from the server\n\n', - "\n\nr\n\n> a fabricated quote goes here\n\n\n\nEnd." - ) - - chunks <- stream_citations_fixture(agent, raw, split_at = 30) - concatenated <- paste(unlist(chunks), collapse = "") - - expect_identical( - concatenated, - paste0(project_citation_text(raw, agent$citation_corpus())$text, "\n") +test_that("$search() names the commons object's methods", { + tr <- commons( + data_sources = list(sales_db = definitions_source()), + semantic_layer = semantic_layer(count_measure_tool()) ) - expect_false(any(grepl("", unlist(chunks)))) - expect_false(any(grepl("spoofed", unlist(chunks), fixed = TRUE))) - turns <- agent$get_turns() - stored_text <- turns[[length(turns)]]@contents[[1]]@text - expect_identical(stored_text, raw) + expect_snapshot(tr$search("big revenue region orders")) }) -test_that("stream_async preserves structured provider content", { - skip_if_ellmer_streaming_hooks_unavailable() - structured <- ellmer::ContentThinking("provider citation metadata") - final_turn <- ellmer::AssistantTurn( - list( - ellmer::ContentText("Before "), - structured, - ellmer::ContentText("after.") - ), - tokens = c(0, 0, 0), - cost = 0 - ) - make_response <- function() { - coro::async_generator(function() { - for (content in final_turn@contents) { - yield(list(content = content)) - } - coro::exhausted() - })() - } - testthat::local_mocked_bindings( - chat_perform = function(...) make_response(), - stream_merge_chunks = function(provider, result, chunk) chunk, - stream_content_with_turns = function(provider, event, completion, turns) { - list(event$content) - }, - value_finish_reason = function(provider, result) "stop", - value_turn_with_turns = function( - provider, - model, - result, - has_type = FALSE, - turns = list() - ) { - final_turn - }, - .package = "ellmer" - ) - agent <- test_agent() - - chunks <- sync_promise(coro::async_collect( - agent$stream_async("Use provider evidence.", stream = "content") +test_that("$metrics() accepts `where` as a string or unnamed triples", { + tr <- commons(data_sources = list(sales_db = definitions_source())) + emea <- tr$metrics("big_revenue", where = list( + list(column = "region", op = "=", value = "EMEA"), + list(column = "revenue", op = ">", value = 0) )) - expect_s7_class(chunks[[1]], ellmer::ContentText) - expect_identical(chunks[[1]]@text, "Before ") - expect_identical(chunks[[2]], structured) - expect_s7_class(chunks[[3]], ellmer::ContentText) - expect_identical(chunks[[3]]@text, "after.") -}) - -test_that("stream_async records provenance at span creation and completion", { - skip_if_not_installed("otelsdk") - withr::local_envvar( - OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = "true" - ) - path <- withr::local_tempfile(fileext = ".md") - writeLines("Canopy cover is always acre-weighted for reporting.", path) - local_mocked_bindings(collect_appended_tags = function(...) "B") - - raw <- paste0( - "Answer sentence.\n\n", - "\n\nFollows the weighting rule.\n\n", - "> Canopy cover is always acre-weighted for reporting.\n\n", - "\n\nMore text.\n\n", - "\n\nr\n\n> a fabricated quote goes here\n\n\n\nEnd." + expect_equal( + tr$metrics("big_revenue", where = "region = 'EMEA' and revenue > 0"), + emea ) - - recorded <- otelsdk::with_otel_record({ - agent <- test_agent(context_layer = context_layer(files = path), log = TRUE) - stream_citations_fixture(agent, raw, split_at = 30) - }) - - names <- vapply(recorded$traces, `[[`, character(1), "name") - span <- recorded$traces[[which(names == "commons_conversation_turn")]] - provenance_span <- recorded$traces[[which(names == "commons_provenance")]] - expect_identical(span$attributes[["commons.provenance.tag"]], "B") - expect_identical( - provenance_span$attributes[["commons.provenance.tag"]], - "B" + expect_equal( + tr$metrics( + "big_revenue", + where = list(c("region", "=", "EMEA"), list("revenue", ">", 0)) + ), + emea ) - expect_identical(provenance_span$parent, span$span_id) - - candidates <- jsonlite::fromJSON( - provenance_span$attributes[["commons.citation.candidates"]], - simplifyVector = FALSE + expect_equal( + tr$metrics("big_revenue", where = c("region", "=", "EMEA")), + tr$metrics("big_revenue", where = "region = 'EMEA'") ) - expect_equal( - candidates, - list( - list( - quote = "Canopy cover is always acre-weighted for reporting.", - status = "accepted", - label = "documentation", - kind = "prose" - ), - list( - quote = "a fabricated quote goes here", - status = "rejected" - ) - ) + as_where_triples("name <> 'O''Brien'")[[1]], + list(column = "name", op = "!=", value = "O'Brien") ) + expect_snapshot(error = TRUE, { + tr$metrics("big_revenue", where = "region IN ('EMEA', 'APAC')") + tr$metrics("big_revenue", where = list(c("region", "EMEA"))) + }) }) -test_that("a trusted-only agent streams no provenance or citation markers", { - skip_if_not_installed("otelsdk") - withr::local_envvar( - OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = "true" - ) - path <- withr::local_tempfile(fileext = ".md") - writeLines("Canopy cover is always acre-weighted for reporting.", path) - local_mocked_bindings(collect_appended_tags = function(...) "A") - - raw <- paste0( - "Answer sentence.\n\n", - "\n\nFollows the weighting rule.\n\n", - "> Canopy cover is always acre-weighted for reporting.\n\n", - "\n\nEnd." +test_that("commons() prints its contents and method signatures", { + tr <- commons( + data_sources = list(sales_db = definitions_source()), + semantic_layer = semantic_layer(count_measure_tool()) ) - recorded <- otelsdk::with_otel_record({ - agent <- test_agent( - context_layer = context_layer(files = path), - semantic_layer = semantic_layer(count_measure_tool()), - mode = "trusted only", - log = TRUE - ) - expect_true( - match_citation( - "Canopy cover is always acre-weighted for reporting.", - agent$citation_corpus() - )$kind == "prose" - ) - agent$queue_restore_reminder() - first <- stream_citations_fixture(agent, raw, split_at = 30) - # A second user turn exercises add_turn() without a citation tracker. - second <- stream_citations_fixture(agent, raw, split_at = 12) - }) - - for (chunks in list(first, second)) { - text <- paste(unlist(chunks), collapse = "") - expect_no_match(text, "", 0)) - ), - emea - ) - expect_equal( - tr$metrics("big_revenue", where = c("region", "=", "EMEA")), - tr$metrics("big_revenue", where = "region = 'EMEA'") - ) - expect_equal( - as_where_triples("name <> 'O''Brien'")[[1]], - list(column = "name", op = "!=", value = "O'Brien") - ) - expect_snapshot(error = TRUE, { - tr$metrics("big_revenue", where = "region IN ('EMEA', 'APAC')") - tr$metrics("big_revenue", where = list(c("region", "EMEA"))) - }) -}) - -test_that("trusted() prints its contents and method signatures", { - tr <- trusted( - data_sources = list(sales_db = definitions_source()), - semantic_layer = semantic_layer(count_measure_tool()) - ) - - expect_snapshot(tr) -}) - -test_that("trusted() lists only the calculation methods it can run", { - expect_snapshot(trusted(list(sales_db = test_source()))) -}) diff --git a/pkg-r/vignettes/commons.Rmd b/pkg-r/vignettes/commons.Rmd index a98bc1c25..2b0f45f4b 100644 --- a/pkg-r/vignettes/commons.Rmd +++ b/pkg-r/vignettes/commons.Rmd @@ -113,7 +113,7 @@ The following table details the various ways each provenance outcome can occur: The agent itself does not determine the provenance outcome. commons derives it deterministically from the agent's behavior. -To turn off the lower-trust path altogether, use `commons(mode = "trusted only")`. In this mode, the agent can't write its own SQL or R code; it answers only with trusted calculations. If no trusted calculation answers a question, it tells the user so. Since every answer comes from a trusted calculation, answers carry no provenance markers. +To turn off the lower-trust path altogether, use `agent(mode = "trusted only")`. In this mode, the agent can't write its own SQL or R code; it answers only with trusted calculations. If no trusted calculation answers a question, it tells the user so. Since every answer comes from a trusted calculation, answers carry no provenance markers. ## Information layers @@ -265,7 +265,7 @@ A commons agent is easiest to maintain when the pieces live in separate files: ## Constructing the agent -Use `commons()` to construct an agent. Pass it an ellmer `Chat` and one or more data sources, along with any semantic and context layers. You can also optionally append information to the commons agent system prompt using the `instructions` argument. +Construct an agent in two steps. First, use `commons()` to gather one or more data sources, along with any semantic and context layers, into "the commons". Then pass an ellmer `Chat` and the commons to `agent()`. You can also optionally append information to the commons agent system prompt using the `instructions` argument to `agent()`. ```r library(commons) @@ -276,11 +276,15 @@ biodiversity <- data_source( dictionary = "dictionaries/biodiversity.yaml" ) -agent <- commons( - client = ellmer::chat("anthropic/claude-sonnet-5"), +commons <- commons( data_sources = list(biodiversity = biodiversity), semantic_layer = semantic_layer("measures"), - context_layer = context_layer("context/context.md"), + context_layer = context_layer("context/context.md") +) + +agent <- agent( + client = ellmer::chat("anthropic/claude-sonnet-5"), + commons = commons, instructions = "instructions.md" ) diff --git a/pkg-r/vignettes/governance.Rmd b/pkg-r/vignettes/governance.Rmd index 874645df6..358fa8580 100644 --- a/pkg-r/vignettes/governance.Rmd +++ b/pkg-r/vignettes/governance.Rmd @@ -26,7 +26,7 @@ A commons agent is an ellmer chat with a system prompt and a set of tools. The s We assume that a model might make any request allowed by its tools. Application security should therefore not depend on the model following an instruction like "never reveal sensitive data." Instead, only give an agent access to data that the current user of the application is allowed to see. The system prompt, tool arguments, and tool results are also sent to the model provider, so the agent should not have access to data that you [do not trust that provider to process](https://posit.co/blog/trust-llm-tools). -An agent created with `commons(mode = "trusted only")` has neither `run_sql` nor `run_r`. It can run only trusted calculations and inspect table schemas, so the SQL and R code execution sections below don't apply to it. +An agent created with `agent(mode = "trusted only")` has neither `run_sql` nor `run_r`. It can run only trusted calculations and inspect table schemas, so the SQL and R code execution sections below don't apply to it. ## SQL code execution @@ -60,7 +60,7 @@ On Linux (which underlies [Posit Connect](https://posit.co/products/enterprise/c The filesystem sandbox gives the subprocess read access to R, installed packages, and required operating-system directories. It can write only to temporary directories used by the subprocess and its communication with the main R process. The seccomp filters prevent it from inspecting another process's memory or changing mount and namespace configuration. The Linux sandbox also caps the subprocess's virtual address space at 8 GiB; a lower limit inherited from Connect still applies. -By default, the subprocess cannot create network sockets. An application author can opt in to network access with `commons(network = "full")`; the filesystem sandbox remains in place, but R code can then contact services reachable from the deployment and transmit data from result handles. Only enable network access when that egress is required and acceptable. +By default, the subprocess cannot create network sockets. An application author can opt in to network access with `agent(network = "full")`; the filesystem sandbox remains in place, but R code can then contact services reachable from the deployment and transmit data from result handles. Only enable network access when that egress is required and acceptable. For local development on macOS, commons applies a similar filesystem and network policy using Seatbelt. Windows has no OS-level sandbox for the agent's R session. Deployed Connect applications run on Linux and use the Linux mechanisms described above. @@ -88,8 +88,8 @@ Only include facts and source code that may be shared with both the application' ## Logging trajectories -Trajectory logging is disabled by default. With `commons(log = TRUE)`, commons asks ellmer to include message content in OpenTelemetry spans. These spans contain full conversation histories, including user messages, model responses, tool requests, and tool results. Treat them as another copy of the data available to the agent. +Trajectory logging is disabled by default. With `agent(log = TRUE)`, commons asks ellmer to include message content in OpenTelemetry spans. These spans contain full conversation histories, including user messages, model responses, tool requests, and tool results. Treat them as another copy of the data available to the agent. -On Connect, both the server administrator and application author must opt in for the data to flow. The administrator must enable OpenTelemetry and allow content instrumentation. The application author sets `commons(log = TRUE)` and includes `"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"` in the `envVars` argument to `rsconnect::deployApp()`. commons enables [Content Observability](https://docs.posit.co/connect/user/content-settings/#content-observability) for the content item when needed, although capture begins only after the content process restarts. +On Connect, both the server administrator and application author must opt in for the data to flow. The administrator must enable OpenTelemetry and allow content instrumentation. The application author sets `agent(log = TRUE)` and includes `"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"` in the `envVars` argument to `rsconnect::deployApp()`. commons enables [Content Observability](https://docs.posit.co/connect/user/content-settings/#content-observability) for the content item when needed, although capture begins only after the content process restarts. Content owners and collaborators can read Connect trajectories with `trajectory_read()`. If a conversation is flagged or annotated in `trajectory_review()`, the reviewer writes a Markdown document containing the complete reviewer-visible conversation and its tool activity. Store these files only in an appropriately protected location, and carry out reviews in an environment trusted to handle internal user data, such as [Posit Workbench](https://posit.co/products/enterprise/workbench). Retention and access controls for the underlying OpenTelemetry data are determined by Connect or the configured exporter, not by commons. From 5edc0bf5ebc1a0e6b2e03a8b920f3f7d63410722 Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Mon, 26 Oct 2026 08:29:57 -0500 Subject: [PATCH 11/17] rewrite NEWS.md --- pkg-r/NEWS.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md index 70a0a53fc..f6b7c3eb4 100644 --- a/pkg-r/NEWS.md +++ b/pkg-r/NEWS.md @@ -1,8 +1,23 @@ # commons (development version) -* `commons()` now gathers data sources, semantic layer, and context layer into "the commons", an object that code can call directly without a model, such as from a coding agent's R session. Its arguments must be named. The new `agent()` builds an agent from a client and the commons, replacing the old role of `commons()`. Calls that pass a client to `commons()` still work and return an agent, with a deprecation warning. +* The `commons()` function was split into two. Calls to `commons()` previously looked like this: -* `agent()` gains a `mode` argument. With `mode = "trusted only"`, the agent answers only with trusted calculations. It never writes its own SQL or R, and it tells the user when no trusted calculation answers a question. These answers carry no provenance markers. + ```r + commons <- commons(client, data_sources, semantic_layer, context_layer) + ``` + + The construction of the data structure that holds the data sources, semantic layer, and context layer and the construction of the agent harness that makes that information available to an LLM are now split into two pieces. + + ```r + commons <- commons(data_sources = ..., semantic_layer = ..., context_layer = ...) + agent <- agent(client, commons = commons) + ``` + + We call that first data structure "the commons." The commons can be used to invoke trusted code and search trusted context directly from R and is intended for use with other coding agents. See `?commons()` to learn more. `agent()` returns what `commons()` used to return—a commons agent. + + This transition was implemented backward-compatibly; existing calls to `commons()` will raise a deprecation warning with migration instructions. + +* `agent()` (formerly `commons()`) gains a `mode` argument. With `mode = "trusted only"`, the agent answers only with trusted calculations. It never writes its own SQL or R, and it tells the user when no trusted calculation answers a question. These answers carry no provenance markers. * `describe_table` reports complete table schemas and documentation without a sampled-row summary. DuckDB descriptions use database-native types. Snowflake and Databricks continue to use native metadata, while other DBI connections infer R column classes from five sampled rows, or zero rows in trusted-only mode (#319). From ee1da47bc81d61a1e4f554e49c57fc5063b212ab Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Mon, 26 Oct 2026 09:21:06 -0500 Subject: [PATCH 12/17] Document the MCP Console workaround for context search's fts extension --- pkg-r/R/commons.R | 22 ++++++++++++++++++++++ pkg-r/man/commons.Rd | 20 ++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/pkg-r/R/commons.R b/pkg-r/R/commons.R index e27d82434..09d7c9934 100644 --- a/pkg-r/R/commons.R +++ b/pkg-r/R/commons.R @@ -89,6 +89,28 @@ #' Claude Code shows the tool description only once the agent loads the tool, #' so add the sentence to the end of `tool.instructions` as well. #' +#' If the commons has a context layer, `$context()` needs one more step for +#' now. It relies on DuckDB's `fts` extension, which MCP Console's sandbox +#' can't download. MCP Console can install the extension itself, but only +#' into its own cache, which R's duckdb doesn't read by default. Point R at +#' that cache with an absolute path (`~` and environment variables aren't +#' expanded): +#' +#' ```yaml +#' environment: +#' # macOS; on Linux, /home/you/.cache/mcp-console/dependencies/duckdb +#' DUCKDB_R_HOME: /Users/you/Library/Caches/mcp-console/dependencies/duckdb +#' ``` +#' +#' Then add one more sentence to both `tool.description` and +#' `tool.instructions`, so the agent asks MCP Console to install the +#' extension: +#' +#' ```markdown +#' The first time you run `commons$context()`, include +#' `"requirements": {"duckdb": ["fts"]}` in the same call. +#' ``` +#' #' @section Lifecycle: #' Before commons 0.2.0, `commons()` built the agent itself, taking `client` #' as its first argument. Calls in that form still work, with a deprecation diff --git a/pkg-r/man/commons.Rd b/pkg-r/man/commons.Rd index df117be8e..a8a6226d7 100644 --- a/pkg-r/man/commons.Rd +++ b/pkg-r/man/commons.Rd @@ -99,6 +99,26 @@ tool: Claude Code shows the tool description only once the agent loads the tool, so add the sentence to the end of \code{tool.instructions} as well. + +If the commons has a context layer, \verb{$context()} needs one more step for +now. It relies on DuckDB's \code{fts} extension, which MCP Console's sandbox +can't download. MCP Console can install the extension itself, but only +into its own cache, which R's duckdb doesn't read by default. Point R at +that cache with an absolute path (\code{~} and environment variables aren't +expanded): + +\if{html}{\out{
}}\preformatted{environment: + # macOS; on Linux, /home/you/.cache/mcp-console/dependencies/duckdb + DUCKDB_R_HOME: /Users/you/Library/Caches/mcp-console/dependencies/duckdb +}\if{html}{\out{
}} + +Then add one more sentence to both \code{tool.description} and +\code{tool.instructions}, so the agent asks MCP Console to install the +extension: + +\if{html}{\out{
}}\preformatted{The first time you run `commons$context()`, include +`"requirements": \{"duckdb": ["fts"]\}` in the same call. +}\if{html}{\out{
}} } \section{Lifecycle}{ From dec36cafb70a6d3ac9cbe34038f033383328fe85 Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Mon, 26 Oct 2026 09:40:11 -0500 Subject: [PATCH 13/17] Fix quoted predicates and lazy pins in commons methods --- pkg-r/NEWS.md | 2 ++ pkg-r/R/commons.R | 7 ++-- pkg-r/R/pool.R | 4 ++- pkg-r/R/semantic-models.R | 4 ++- pkg-r/tests/testthat/test-commons.R | 54 ++++++++++++++++++++++++++++- 5 files changed, 66 insertions(+), 5 deletions(-) diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md index f6b7c3eb4..6c229f280 100644 --- a/pkg-r/NEWS.md +++ b/pkg-r/NEWS.md @@ -19,6 +19,8 @@ * `agent()` (formerly `commons()`) gains a `mode` argument. With `mode = "trusted only"`, the agent answers only with trusted calculations. It never writes its own SQL or R, and it tells the user when no trusted calculation answers a question. These answers carry no provenance markers. +* `commons()` preserves quoted numeric strings in `$metrics()` predicates and loads pending pins before returning `$connection()` (#414). + * `describe_table` reports complete table schemas and documentation without a sampled-row summary. DuckDB descriptions use database-native types. Snowflake and Databricks continue to use native metadata, while other DBI connections infer R column classes from five sampled rows, or zero rows in trusted-only mode (#319). * On macOS, R code run by `run_r` can now start other programs, such as through `system()` or `system2()`. The sandbox previously stopped every program it started. Those programs run under the same sandbox as the R session. diff --git a/pkg-r/R/commons.R b/pkg-r/R/commons.R index 09d7c9934..98edfa9e8 100644 --- a/pkg-r/R/commons.R +++ b/pkg-r/R/commons.R @@ -284,6 +284,7 @@ TheCommons <- R6::R6Class( connection = function(source = NULL) { src <- resolve_sql_source(private$state$sources, source) + source_ensure_all(src) data_source_state(src)$con } ), @@ -453,14 +454,16 @@ parse_where_string <- function(where, call = rlang::caller_env()) { abort_where(call) } value <- match[[4]] - if (startsWith(value, "'")) { + quoted <- startsWith(value, "'") + if (quoted) { value <- gsub("''", "'", substr(value, 2, nchar(value) - 1)) } op <- if (identical(match[[3]], "<>")) "!=" else match[[3]] triples[[length(triples) + 1]] <- list( column = match[[2]], op = op, - value = value + value = value, + quoted = quoted ) rest <- substring(rest, nchar(match[[1]]) + 1) } diff --git a/pkg-r/R/pool.R b/pkg-r/R/pool.R index 0262d9c34..04843cf9a 100644 --- a/pkg-r/R/pool.R +++ b/pkg-r/R/pool.R @@ -495,7 +495,9 @@ compile_where_triple <- function(triple, columns, con) { not {.val {triple$op}}." ) } - value <- if (grepl("^-?[0-9]+(\\.[0-9]+)?$", triple$value)) { + value <- if ( + !isTRUE(triple$quoted) && grepl("^-?[0-9]+(\\.[0-9]+)?$", triple$value) + ) { triple$value } else { as.character(DBI::dbQuoteString(con, triple$value)) diff --git a/pkg-r/R/semantic-models.R b/pkg-r/R/semantic-models.R index ccd7f7bdd..002ca407a 100644 --- a/pkg-r/R/semantic-models.R +++ b/pkg-r/R/semantic-models.R @@ -743,7 +743,9 @@ semantic_where_conditions <- function( call = call ) reference <- member_references(dimension, con) - value <- if (grepl("^-?[0-9]+(\\.[0-9]+)?$", triple$value)) { + value <- if ( + !isTRUE(triple$quoted) && grepl("^-?[0-9]+(\\.[0-9]+)?$", triple$value) + ) { triple$value } else { as.character(DBI::dbQuoteString(con, triple$value)) diff --git a/pkg-r/tests/testthat/test-commons.R b/pkg-r/tests/testthat/test-commons.R index 623cd7b20..4c37802dd 100644 --- a/pkg-r/tests/testthat/test-commons.R +++ b/pkg-r/tests/testthat/test-commons.R @@ -7,6 +7,23 @@ test_that("commons() runs measures", { expect_equal(tr$measure("order_count", arguments = list(region = "EMEA")), 3) }) +test_that("$connection() loads pending pins before returning the connection", { + skip_if_not_installed("pins") + sales <- test_sales() + board <- board_with_pins(sales = sales, orders = sales[1:2, ]) + tr <- commons( + data_sources = data_source( + board, + tables = c(sales = "sales", orders = "orders") + ) + ) + + con <- tr$connection() + expect_equal(DBI::dbGetQuery(con, "SELECT * FROM sales"), sales) + expect_equal(DBI::dbGetQuery(con, "SELECT * FROM orders"), sales[1:2, ]) + expect_identical(tr$connection(), con) +}) + test_that("agent() builds an agent around a commons() object", { withr::local_options(commons.allow_unsafe_fallback = TRUE) tr <- commons(data_sources = list(sales_db = test_source())) @@ -126,7 +143,7 @@ test_that("$metrics() accepts `where` as a string or unnamed triples", { ) expect_equal( as_where_triples("name <> 'O''Brien'")[[1]], - list(column = "name", op = "!=", value = "O'Brien") + list(column = "name", op = "!=", value = "O'Brien", quoted = TRUE) ) expect_snapshot(error = TRUE, { tr$metrics("big_revenue", where = "region IN ('EMEA', 'APAC')") @@ -134,6 +151,41 @@ test_that("$metrics() accepts `where` as a string or unnamed triples", { }) }) +test_that("$metrics() preserves quoted numeric strings in where", { + sales <- data.frame(region = c("001", "1"), revenue = c(10, 20)) + tr <- commons( + data_sources = data_source( + sales = sales, + dictionary = local_definitions_dict(c( + " - name: n_orders", + " expr: ROW_COUNT()" + )) + ) + ) + + expect_equal(tr$metrics("n_orders", where = "region = '001'")$n_orders, 1) + expect_equal(tr$metrics("n_orders", where = "region = '1'")$n_orders, 1) + expect_equal(tr$metrics("n_orders", where = "region = 1")$n_orders, 2) +}) + +test_that("parsed where literals retain quoting for native semantic metrics", { + source <- test_semantic_source() + members <- registry_semantic_members(semantic_models_registry(list( + db = source + ))) + con <- data_source_state(source)$con + + expect_equal( + semantic_where_conditions( + as_where_triples("region = '001' AND region <> 1"), + members, + con, + member_references = function(dimension, con) "region" + ), + c("(region = '001')", "(region != 1)") + ) +}) + test_that("commons() prints its contents and method signatures", { tr <- commons( data_sources = list(sales_db = definitions_source()), From befbef029200a108a5c69473166017ea55597fef Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Tue, 27 Oct 2026 08:15:08 -0500 Subject: [PATCH 14/17] Replace commons' $connection() with $run_sql() $run_sql() expands governed definitions as {{name}} tokens and refuses writes, as the agent's run_sql tool does. MCP Console now builds commons from .Rprofile, since its startup block must select a SQL connection. --- pkg-r/NEWS.md | 1 - pkg-r/R/commons.R | 50 +++++++++++++++----------- pkg-r/R/definitions.R | 10 ++++-- pkg-r/R/pool.R | 37 ++++++++++++------- pkg-r/man/commons.Rd | 35 +++++++++--------- pkg-r/tests/testthat/_snaps/commons.md | 25 ++++++++++--- pkg-r/tests/testthat/test-commons.R | 34 ++++++++++++++---- 7 files changed, 127 insertions(+), 65 deletions(-) diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md index 6c229f280..5850429f1 100644 --- a/pkg-r/NEWS.md +++ b/pkg-r/NEWS.md @@ -19,7 +19,6 @@ * `agent()` (formerly `commons()`) gains a `mode` argument. With `mode = "trusted only"`, the agent answers only with trusted calculations. It never writes its own SQL or R, and it tells the user when no trusted calculation answers a question. These answers carry no provenance markers. -* `commons()` preserves quoted numeric strings in `$metrics()` predicates and loads pending pins before returning `$connection()` (#414). * `describe_table` reports complete table schemas and documentation without a sampled-row summary. DuckDB descriptions use database-native types. Snowflake and Databricks continue to use native metadata, while other DBI connections infer R column classes from five sampled rows, or zero rows in trusted-only mode (#319). diff --git a/pkg-r/R/commons.R b/pkg-r/R/commons.R index 98edfa9e8..c83c3cda7 100644 --- a/pkg-r/R/commons.R +++ b/pkg-r/R/commons.R @@ -26,8 +26,10 @@ #' * `$calculation(name, arguments, source)` runs an exact trusted query. #' * `$context(query)` searches trusted context. #' * `$describe(table, source)` describes a table. -#' * `$connection(source)` returns a source's DBI connection, for queries -#' that no trusted calculation answers. +#' * `$run_sql(query, source)` runs a read-only query and returns a data frame. +#' Governed definitions can be written as `{{name}}` tokens (or +#' `{{table::name}}` when qualification is needed), and each expands to its +#' compiled SQL before the query runs. #' #' @section Using commons with other coding agents: #' `commons()` is the entry point to use commons with external coding agents. @@ -47,10 +49,10 @@ #' ``` #' #' **Claude Code and Codex have no persistent R session of their own.** They can -#' get one through [MCP Console](https://t-kalinowski.github.io/mcp-console/). -#' MCP Console can be configured to run code at startup (which we'll use to -#' build `commons`), and we'll also edit the console tool's description to tell -#' the model to print out `commons` before analyzing data. +#' get one through [MCP Console](https://posit-dev.github.io/mcp-console/). +#' Its R session reads the project's `.Rprofile` like any other, so build +#' `commons` there in the same way, and then edit the console tool's +#' description to tell the model to print out `commons` before analyzing data. #' #' To use MCP console, register it for the project, from the project directory: #' @@ -59,23 +61,17 @@ #' uvx mcp-console configure-client codex --directory . #' ``` #' -#' Then configure MCP Console to build `commons` at startup and to mention it -#' in its tool description. A configured description replaces the one MCP -#' Console generates, so start from an export of the resolved configuration: +#' A configured description replaces the one MCP Console generates, so start +#' from an export of the resolved configuration: #' #' ```bash #' uvx mcp-console config resolve --full > .agents/console/config.yaml #' ``` #' -#' In that file, set the startup code, and add the same sentence to the end of -#' the exported description: +#' In that file, add the same sentence to the end of the exported +#' description: #' #' ```yaml -#' startup: -#' language: r -#' code: | -#' commons <- acme.metrics::commons() -#' .console$sql_connection(commons$connection()) #' tool: #' description: |- #' # ...the exported description, unchanged... @@ -111,6 +107,11 @@ #' `"requirements": {"duckdb": ["fts"]}` in the same call. #' ``` #' +#' In either setup, `$run_sql()` refuses queries that write, but that check +#' isn't a security boundary. Code that the agent runs in the same R session +#' can reach anything the session can, including the connections behind +#' `commons`. Connect to databases with a read-only role. +#' #' @section Lifecycle: #' Before commons 0.2.0, `commons()` built the agent itself, taking `client` #' as its first argument. Calls in that form still work, with a deprecation @@ -282,10 +283,19 @@ TheCommons <- R6::R6Class( commons_text(tool_result_text(describe_table_tool(src, table))) }, - connection = function(source = NULL) { - src <- resolve_sql_source(private$state$sources, source) - source_ensure_all(src) - data_source_state(src)$con + run_sql = function(query, source = NULL) { + s <- private$state + src <- resolve_sql_source(s$sources, source) + expansion <- expand_for_run_sql( + s$definitions, + s$sources, + source, + query, + call = rlang::current_env() + ) + # source_query() checks too, but this attributes a refusal to `$run_sql()`. + check_query(expansion$sql) + source_query(src, expansion$sql) } ), private = list(state = NULL) diff --git a/pkg-r/R/definitions.R b/pkg-r/R/definitions.R index 1c7445ae5..855983a48 100644 --- a/pkg-r/R/definitions.R +++ b/pkg-r/R/definitions.R @@ -160,12 +160,18 @@ definition_token_pattern <- # The expansion step of run_sql, before check_query() so the denylist sees # the SQL that will actually execute. -expand_for_run_sql <- function(registry, sources, source_name, sql) { +expand_for_run_sql <- function( + registry, + sources, + source_name, + sql, + call = rlang::current_env() +) { if (is.null(registry) || nrow(registry$defs) == 0) { return(list(sql = sql, applied = NULL)) } label <- source_name %||% rlang::names2(sources)[[1]] - expand_definitions(sql, registry_defs(registry, label)) + expand_definitions(sql, registry_defs(registry, label), call = call) } expand_definitions <- function(sql, defs, call = rlang::caller_env()) { diff --git a/pkg-r/R/pool.R b/pkg-r/R/pool.R index 04843cf9a..616dfcaa2 100644 --- a/pkg-r/R/pool.R +++ b/pkg-r/R/pool.R @@ -524,8 +524,6 @@ search_pool_text <- function( trusted_only = FALSE, methods = FALSE ) { - # Outside commons, there's no run_sql to expand definition tokens. - trusted_only <- trusted_only || methods defs <- registry_defs(registry) semantic_models <- semantic_models %||% list(members = no_semantic_members) semantic_members <- registry_semantic_members(semantic_models) @@ -592,7 +590,7 @@ search_pool_text <- function( hits <- lexical_rank(query, catalog, n = 5) if (length(hits) == 0) { return(sprintf( - if (trusted_only) { + if (trusted_only || methods) { "Nothing in the semantic layer matches \"%s\"." } else { "Nothing in the semantic layer matches \"%s\". Consider writing a SQL query." @@ -742,37 +740,50 @@ definition_pool_text <- function( defs$table == def$table & defs$kind == "metric" ) + run_sql <- if (methods) "`$run_sql()`" else "run_sql" + call_metrics <- if (methods) "`$metrics()`" else "call_metrics" invoke <- if (trusted_only) { definition_trusted_usage(def, table_has_metrics, methods = methods) } else if (def$mixed_grain) { sprintf( - "Use `{{%s}}` only in run_sql with manually grain-correct query structure.", - def$name + "Use `{{%s}}` only in %s with manually grain-correct query structure.", + def$name, + run_sql ) } else { switch( kind, filter = if (table_has_metrics) { sprintf( - "Apply in run_sql (e.g. `WHERE {{%s}}`) or as a call_metrics filter or dimension.", - def$name + "Apply in %s (e.g. `WHERE {{%s}}`) or as a %s filter or dimension.", + run_sql, + def$name, + call_metrics ) } else { - sprintf("Apply in run_sql (e.g. `WHERE {{%s}}`).", def$name) + sprintf("Apply in %s (e.g. `WHERE {{%s}}`).", run_sql, def$name) }, metric = sprintf( - "Query with call_metrics (metrics = [\"%s\"]) or in run_sql as `SELECT {{%s}} AS value`.", - def$name, + "Query with %s or in %s as `SELECT {{%s}} AS value`.", + if (methods) { + sprintf("`$metrics(\"%s\")`", def$name) + } else { + sprintf("call_metrics (metrics = [\"%s\"])", def$name) + }, + run_sql, def$name ), if (table_has_metrics) { sprintf( - "Use in run_sql SELECT or GROUP BY as `{{%s}}`, or as a call_metrics dimension.", - def$name + "Use in %s SELECT or GROUP BY as `{{%s}}`, or as a %s dimension.", + run_sql, + def$name, + call_metrics ) } else { sprintf( - "Use in run_sql SELECT or GROUP BY as `{{%s}}`.", + "Use in %s SELECT or GROUP BY as `{{%s}}`.", + run_sql, def$name ) } diff --git a/pkg-r/man/commons.Rd b/pkg-r/man/commons.Rd index a8a6226d7..e5560aaf6 100644 --- a/pkg-r/man/commons.Rd +++ b/pkg-r/man/commons.Rd @@ -30,8 +30,10 @@ columns, either as a string like \item \verb{$calculation(name, arguments, source)} runs an exact trusted query. \item \verb{$context(query)} searches trusted context. \item \verb{$describe(table, source)} describes a table. -\item \verb{$connection(source)} returns a source's DBI connection, for queries -that no trusted calculation answers. +\item \verb{$run_sql(query, source)} runs a read-only query and returns a data frame. +Governed definitions can be written as \code{{{name}}} tokens (or +\code{{{table::name}}} when qualification is needed), and each expands to its +compiled SQL before the query runs. } } \description{ @@ -61,10 +63,10 @@ calculations where they apply in favor of writing analysis code ad-hoc. }\if{html}{\out{
}} \strong{Claude Code and Codex have no persistent R session of their own.} They can -get one through \href{https://t-kalinowski.github.io/mcp-console/}{MCP Console}. -MCP Console can be configured to run code at startup (which we'll use to -build \code{commons}), and we'll also edit the console tool's description to tell -the model to print out \code{commons} before analyzing data. +get one through \href{https://posit-dev.github.io/mcp-console/}{MCP Console}. +Its R session reads the project's \code{.Rprofile} like any other, so build +\code{commons} there in the same way, and then edit the console tool's +description to tell the model to print out \code{commons} before analyzing data. To use MCP console, register it for the project, from the project directory: @@ -72,22 +74,16 @@ To use MCP console, register it for the project, from the project directory: uvx mcp-console configure-client codex --directory . }\if{html}{\out{
}} -Then configure MCP Console to build \code{commons} at startup and to mention it -in its tool description. A configured description replaces the one MCP -Console generates, so start from an export of the resolved configuration: +A configured description replaces the one MCP Console generates, so start +from an export of the resolved configuration: \if{html}{\out{
}}\preformatted{uvx mcp-console config resolve --full > .agents/console/config.yaml }\if{html}{\out{
}} -In that file, set the startup code, and add the same sentence to the end of -the exported description: +In that file, add the same sentence to the end of the exported +description: -\if{html}{\out{
}}\preformatted{startup: - language: r - code: | - commons <- acme.metrics::commons() - .console$sql_connection(commons$connection()) -tool: +\if{html}{\out{
}}\preformatted{tool: description: |- # ...the exported description, unchanged... @@ -119,6 +115,11 @@ extension: \if{html}{\out{
}}\preformatted{The first time you run `commons$context()`, include `"requirements": \{"duckdb": ["fts"]\}` in the same call. }\if{html}{\out{
}} + +In either setup, \verb{$run_sql()} refuses queries that write, but that check +isn't a security boundary. Code that the agent runs in the same R session +can reach anything the session can, including the connections behind +\code{commons}. Connect to databases with a read-only role. } \section{Lifecycle}{ diff --git a/pkg-r/tests/testthat/_snaps/commons.md b/pkg-r/tests/testthat/_snaps/commons.md index f88ae2e8b..6743ea399 100644 --- a/pkg-r/tests/testthat/_snaps/commons.md +++ b/pkg-r/tests/testthat/_snaps/commons.md @@ -1,3 +1,18 @@ +# $run_sql() expands governed definitions + + Code + tr$run_sql("SELECT COUNT(*) FROM sales WHERE {{not_a_definition}}") + Condition + Error in `tr$run_sql()`: + ! No governed definition matches `{{not_a_definition}}`. + i Available definitions: `{{emea}} (sales)`, `{{big_revenue}} (sales)`, and `{{region_band}} (sales)`. + Code + tr$run_sql("DELETE FROM sales") + Condition + Error in `tr$run_sql()`: + ! The query contains a disallowed operation: `DELETE`. + i Only read-only SELECT queries are allowed. + # commons() requires named arguments Code @@ -105,7 +120,7 @@ Revenue over big EMEA orders. Selected SQL(duckdb): `(sum(CASE WHEN ("region" = 'EMEA') AND "revenue" > 600 THEN "revenue" ELSE 0 END))`. Translation notes: DuckDB compares a NaN as equal to itself and greater than every number, where data-dict answers false; a row holding one passes here and is reported there. DuckDB sums integers at 128 bits, so a total data-dict reports as an overflow (D09) may succeed. - Query with `$metrics("big_revenue")`. + Query with `$metrics("big_revenue")` or in `$run_sql()` as `SELECT {{big_revenue}} AS value`. Filters and derived definitions on this table: {{emea}} (filter), {{region_band}} (derived). ### order_count @@ -119,12 +134,12 @@ ### {{emea}} --- filter on table `sales` EMEA rows only. Selected SQL(duckdb): `("region" = 'EMEA')`. - Use as a `$metrics()` filter or dimension. + Apply in `$run_sql()` (e.g. `WHERE {{emea}}`) or as a `$metrics()` filter or dimension. ### {{region_band}} --- derived on table `sales` Coarse region grouping. Selected SQL(duckdb): `(CASE WHEN ("region" = 'EMEA') THEN 'east' ELSE 'west' END)`. - Use as a `$metrics()` dimension. + Use in `$run_sql()` SELECT or GROUP BY as `{{region_band}}`, or as a `$metrics()` dimension. # $metrics() accepts `where` as a string or unnamed triples @@ -159,7 +174,7 @@ $metrics(metrics, dimensions = NULL, filters = NULL, where = NULL, arguments = list(), source = NULL) $context(query) $describe(table, source = NULL) - $connection(source = NULL) + $run_sql(query, source = NULL) This object provides access to trusted code and context. Before writing your own analysis code or SQL, look for existing code with `$search()` and run it with `$measure()` or `$metrics()`. When none fits, check @@ -177,7 +192,7 @@ $search(query) $context(query) $describe(table, source = NULL) - $connection(source = NULL) + $run_sql(query, source = NULL) This object provides access to trusted code and context. Before writing your own analysis code or SQL, check `$context("your query")` for guidance on the approach. Say in your answer which numbers came from diff --git a/pkg-r/tests/testthat/test-commons.R b/pkg-r/tests/testthat/test-commons.R index 4c37802dd..dd5ff5682 100644 --- a/pkg-r/tests/testthat/test-commons.R +++ b/pkg-r/tests/testthat/test-commons.R @@ -7,21 +7,41 @@ test_that("commons() runs measures", { expect_equal(tr$measure("order_count", arguments = list(region = "EMEA")), 3) }) -test_that("$connection() loads pending pins before returning the connection", { +test_that("$run_sql() expands governed definitions", { + tr <- commons(data_sources = list(sales_db = definitions_source())) + plain <- tr$run_sql("SELECT COUNT(*) AS n FROM sales WHERE region = 'EMEA'") + expect_equal(plain$n, sum(test_sales()$region == "EMEA")) + + expect_equal( + tr$run_sql("SELECT COUNT(*) AS n FROM sales WHERE {{emea}}"), + plain + ) + expect_equal( + tr$run_sql("SELECT COUNT(*) AS n FROM sales WHERE {{sales::emea}}"), + plain + ) + expect_equal( + tr$run_sql("SELECT {{big_revenue}} AS value FROM sales")$value, + tr$metrics("big_revenue")$big_revenue + ) + expect_snapshot(error = TRUE, { + tr$run_sql("SELECT COUNT(*) FROM sales WHERE {{not_a_definition}}") + tr$run_sql("DELETE FROM sales") + }) +}) + +test_that("$run_sql() loads pending pins", { skip_if_not_installed("pins") sales <- test_sales() - board <- board_with_pins(sales = sales, orders = sales[1:2, ]) tr <- commons( data_sources = data_source( - board, + board_with_pins(sales = sales, orders = sales[1:2, ]), tables = c(sales = "sales", orders = "orders") ) ) - con <- tr$connection() - expect_equal(DBI::dbGetQuery(con, "SELECT * FROM sales"), sales) - expect_equal(DBI::dbGetQuery(con, "SELECT * FROM orders"), sales[1:2, ]) - expect_identical(tr$connection(), con) + expect_equal(tr$run_sql("SELECT * FROM sales"), sales) + expect_equal(tr$run_sql("SELECT * FROM orders"), sales[1:2, ]) }) test_that("agent() builds an agent around a commons() object", { From 0a7131fe09e12a898e0ae84dfddd9446684234c9 Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Tue, 27 Oct 2026 08:30:12 -0500 Subject: [PATCH 15/17] document `languages: [r]` --- pkg-r/R/commons.R | 22 +++++++++++++++------- pkg-r/man/commons.Rd | 26 +++++++++++++++++--------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/pkg-r/R/commons.R b/pkg-r/R/commons.R index c83c3cda7..ae25e80b3 100644 --- a/pkg-r/R/commons.R +++ b/pkg-r/R/commons.R @@ -61,17 +61,24 @@ #' uvx mcp-console configure-client codex --directory . #' ``` #' -#' A configured description replaces the one MCP Console generates, so start -#' from an export of the resolved configuration: +#' Then configure MCP Console to offer the model only R, so that it queries +#' data through `commons$run_sql()` rather than sending SQL to a database +#' itself, and to mention `commons` in its tool description. A configured +#' description replaces the one MCP Console generates, so start from an export +#' of the resolved configuration: #' #' ```bash -#' uvx mcp-console config resolve --full > .agents/console/config.yaml +#' mkdir -p .agents/console +#' uvx mcp-console config resolve --full --no-project-config \ +#' -c 'languages=[r]' > .agents/console/config.yaml #' ``` #' -#' In that file, add the same sentence to the end of the exported -#' description: +#' In that file, keep `languages: [r]`, and add the same sentence to the end +#' of the exported description: #' #' ```yaml +#' languages: +#' - r #' tool: #' description: |- #' # ...the exported description, unchanged... @@ -85,8 +92,9 @@ #' Claude Code shows the tool description only once the agent loads the tool, #' so add the sentence to the end of `tool.instructions` as well. #' -#' If the commons has a context layer, `$context()` needs one more step for -#' now. It relies on DuckDB's `fts` extension, which MCP Console's sandbox +#' `$context()` needs one more step for now. This applies even without a +#' context layer when a data source has a data dictionary or semantic model, +#' since `$context()` searches those too. It relies on DuckDB's `fts` extension, which MCP Console's sandbox #' can't download. MCP Console can install the extension itself, but only #' into its own cache, which R's duckdb doesn't read by default. Point R at #' that cache with an absolute path (`~` and environment variables aren't diff --git a/pkg-r/man/commons.Rd b/pkg-r/man/commons.Rd index e5560aaf6..666c7042b 100644 --- a/pkg-r/man/commons.Rd +++ b/pkg-r/man/commons.Rd @@ -74,16 +74,23 @@ To use MCP console, register it for the project, from the project directory: uvx mcp-console configure-client codex --directory . }\if{html}{\out{
}} -A configured description replaces the one MCP Console generates, so start -from an export of the resolved configuration: - -\if{html}{\out{
}}\preformatted{uvx mcp-console config resolve --full > .agents/console/config.yaml +Then configure MCP Console to offer the model only R, so that it queries +data through \code{commons$run_sql()} rather than sending SQL to a database +itself, and to mention \code{commons} in its tool description. A configured +description replaces the one MCP Console generates, so start from an export +of the resolved configuration: + +\if{html}{\out{
}}\preformatted{mkdir -p .agents/console +uvx mcp-console config resolve --full --no-project-config \\ + -c 'languages=[r]' > .agents/console/config.yaml }\if{html}{\out{
}} -In that file, add the same sentence to the end of the exported -description: +In that file, keep \verb{languages: [r]}, and add the same sentence to the end +of the exported description: -\if{html}{\out{
}}\preformatted{tool: +\if{html}{\out{
}}\preformatted{languages: + - r +tool: description: |- # ...the exported description, unchanged... @@ -96,8 +103,9 @@ description: Claude Code shows the tool description only once the agent loads the tool, so add the sentence to the end of \code{tool.instructions} as well. -If the commons has a context layer, \verb{$context()} needs one more step for -now. It relies on DuckDB's \code{fts} extension, which MCP Console's sandbox +\verb{$context()} needs one more step for now. This applies even without a +context layer when a data source has a data dictionary or semantic model, +since \verb{$context()} searches those too. It relies on DuckDB's \code{fts} extension, which MCP Console's sandbox can't download. MCP Console can install the extension itself, but only into its own cache, which R's duckdb doesn't read by default. Point R at that cache with an absolute path (\code{~} and environment variables aren't From 6df2acf2362e589fc89b1c8b2035765fa6ee395e Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Wed, 28 Oct 2026 08:39:31 -0500 Subject: [PATCH 16/17] Address review: stricter where parsing, hide $context() when empty, clearer empty-list errors --- pkg-r/R/commons.R | 52 ++++++++++++++++---------- pkg-r/R/pool.R | 6 ++- pkg-r/R/semantic-calculations.R | 6 ++- pkg-r/tests/testthat/_snaps/commons.md | 22 ++++++++--- pkg-r/tests/testthat/test-commons.R | 20 ++++++++++ 5 files changed, 80 insertions(+), 26 deletions(-) diff --git a/pkg-r/R/commons.R b/pkg-r/R/commons.R index ae25e80b3..751ff4c20 100644 --- a/pkg-r/R/commons.R +++ b/pkg-r/R/commons.R @@ -347,6 +347,8 @@ commons_overview <- function(state) { runners <- c("measure", "metrics", "calculation")[ c(length(measures) > 0, n_metrics > 0, length(state$calculations) > 0) ] + # Includes dictionary and semantic model context, not just a context layer. + has_context <- !is.null(state$context_layer) run_with <- cli::ansi_collapse( sprintf("`$%s()`", runners), sep2 = " or ", @@ -372,25 +374,35 @@ commons_overview <- function(state) { if (state$has_context_layer) "Business context is available.", "Methods:", commons_method_signatures( - hide = setdiff(c("measure", "metrics", "calculation"), runners) + hide = c( + setdiff(c("measure", "metrics", "calculation"), runners), + if (!has_context) "context" + ) ), strwrap(paste( - "This object provides access to trusted code and context.", - if (length(runners)) { - paste( - "Before writing your own analysis code or SQL, look for existing", - "code with `$search()` and run it with", paste0(run_with, "."), - "When none fits, check `$context(\"your query\")` for guidance on", - "the approach." - ) - } else { - paste( - "Before writing your own analysis code or SQL, check", - "`$context(\"your query\")` for guidance on the approach." - ) - }, - "Say in your answer which numbers came from trusted calculations and", - "which didn't." + c( + "This object provides access to trusted code and context.", + if (length(runners)) { + c( + "Before writing your own analysis code or SQL, look for existing", + "code with `$search()` and run it with", paste0(run_with, "."), + if (has_context) { + c( + "When none fits, check `$context(\"your query\")` for", + "guidance on the approach." + ) + } + ) + } else if (has_context) { + c( + "Before writing your own analysis code or SQL, check", + "`$context(\"your query\")` for guidance on the approach." + ) + }, + "Say in your answer which numbers came from trusted calculations and", + "which didn't." + ), + collapse = " " )) ) } @@ -461,8 +473,10 @@ as_where_triples <- function(where, call = rlang::caller_env()) { parse_where_string <- function(where, call = rlang::caller_env()) { condition <- paste0( - "^\\s*\"?([A-Za-z_][A-Za-z0-9_]*)\"?\\s*(<=|>=|!=|<>|=|<|>)\\s*", - "('(?:[^']|'')*'|-?[0-9]+(?:\\.[0-9]+)?)\\s*(?:(?i:AND)\\s+|$)" + "^\\s*(?|\"([A-Za-z_][A-Za-z0-9_]*)\"|([A-Za-z_][A-Za-z0-9_]*))", + "\\s*(<=|>=|!=|<>|=|<|>)\\s*", + "('(?:[^']|'')*'|-?[0-9]+(?:\\.[0-9]+)?)", + "(?:\\s+(?i:AND)\\s+(?=\\S)|\\s*$)" ) rest <- where triples <- list() diff --git a/pkg-r/R/pool.R b/pkg-r/R/pool.R index 616dfcaa2..14d123a6b 100644 --- a/pkg-r/R/pool.R +++ b/pkg-r/R/pool.R @@ -227,7 +227,11 @@ metric_origin <- function(name, definitions, semantic_members) { ) cli::cli_abort(c( "No governed metric is named {.val {name}}.", - "i" = "Available metrics: {.val {available}}." + "i" = if (length(available)) { + "Available metrics: {.val {available}}." + } else { + "No governed metrics are defined." + } )) } } diff --git a/pkg-r/R/semantic-calculations.R b/pkg-r/R/semantic-calculations.R index 6c628b5a6..ee9ea0e25 100644 --- a/pkg-r/R/semantic-calculations.R +++ b/pkg-r/R/semantic-calculations.R @@ -151,7 +151,11 @@ resolve_calculation <- function( available <- unique(vapply(calculations, `[[`, character(1), "key")) cli::cli_abort(c( "No trusted calculation is named {.val {name}}.", - "i" = "Available calculations: {.val {available}}." + "i" = if (length(available)) { + "Available calculations: {.val {available}}." + } else { + "No trusted calculations are defined." + } ), call = call) } diff --git a/pkg-r/tests/testthat/_snaps/commons.md b/pkg-r/tests/testthat/_snaps/commons.md index 6743ea399..2d2cad951 100644 --- a/pkg-r/tests/testthat/_snaps/commons.md +++ b/pkg-r/tests/testthat/_snaps/commons.md @@ -158,6 +158,21 @@ i Pass a string like `"project = 'dplyr' AND date >= '2026-06-01'"`, or a list of `c(column, op, value)` triples. i Operators: `=`, `!=`, `<`, `<=`, `>`, and `>=`, joined by `AND`. +# missing calculations and metrics don't list empty alternatives + + Code + tr$calculation("nope") + Condition + Error in `run_calculation()`: + ! No trusted calculation is named "nope". + i No trusted calculations are defined. + Code + tr$metrics("nope") + Condition + Error in `FUN()`: + ! No governed metric is named "nope". + i No governed metrics are defined. + # commons() prints its contents and method signatures Code @@ -190,11 +205,8 @@ Sources: sales_db. Tables: sales. Methods: $search(query) - $context(query) $describe(table, source = NULL) $run_sql(query, source = NULL) - This object provides access to trusted code and context. Before writing - your own analysis code or SQL, check `$context("your query")` for - guidance on the approach. Say in your answer which numbers came from - trusted calculations and which didn't. + This object provides access to trusted code and context. Say in your + answer which numbers came from trusted calculations and which didn't. diff --git a/pkg-r/tests/testthat/test-commons.R b/pkg-r/tests/testthat/test-commons.R index dd5ff5682..79030b8da 100644 --- a/pkg-r/tests/testthat/test-commons.R +++ b/pkg-r/tests/testthat/test-commons.R @@ -171,6 +171,26 @@ test_that("$metrics() accepts `where` as a string or unnamed triples", { }) }) +test_that("`where` strings reject malformed conditions", { + expect_equal( + as_where_triples("\"region\" = 'EMEA' AND revenue > 0"), + as_where_triples("region = 'EMEA' AND revenue > 0") + ) + expect_error(as_where_triples("a = 1 AND "), class = "rlang_error") + expect_error(as_where_triples("a = 1AND b = 2"), class = "rlang_error") + expect_error(as_where_triples("\"a = 1"), class = "rlang_error") + expect_error(as_where_triples("a\" = 1"), class = "rlang_error") +}) + +test_that("missing calculations and metrics don't list empty alternatives", { + tr <- commons(data_sources = list(sales_db = test_source())) + + expect_snapshot(error = TRUE, { + tr$calculation("nope") + tr$metrics("nope") + }) +}) + test_that("$metrics() preserves quoted numeric strings in where", { sales <- data.frame(region = c("001", "1"), revenue = c(10, 20)) tr <- commons( From 15aa5d0ef4396283d77fd23af2898f93277d204d Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Wed, 28 Oct 2026 08:42:02 -0500 Subject: [PATCH 17/17] Test $calculation(), $context(), and $describe() on commons objects --- pkg-r/tests/testthat/helper-commons.R | 17 ++++++++ pkg-r/tests/testthat/test-calculations.R | 17 -------- pkg-r/tests/testthat/test-commons.R | 55 ++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 17 deletions(-) diff --git a/pkg-r/tests/testthat/helper-commons.R b/pkg-r/tests/testthat/helper-commons.R index bd2f02dc5..867b5bd7a 100644 --- a/pkg-r/tests/testthat/helper-commons.R +++ b/pkg-r/tests/testthat/helper-commons.R @@ -158,3 +158,20 @@ sync_promise <- function(promise) { success } + +test_calculation <- function() { + new_trusted_calculation( + "preview_value", + "Preview an allowed column with a bound value.", + "SELECT {{column}}, {{value}} AS bound_value FROM sales LIMIT 1", + arguments = list( + new_typed_argument( + "column", + "string", + identifier = TRUE, + choices = c("region", "revenue") + ), + new_typed_argument("value", "number") + ) + ) +} diff --git a/pkg-r/tests/testthat/test-calculations.R b/pkg-r/tests/testthat/test-calculations.R index 8d74868ca..5b1b61204 100644 --- a/pkg-r/tests/testthat/test-calculations.R +++ b/pkg-r/tests/testthat/test-calculations.R @@ -1,20 +1,3 @@ -test_calculation <- function() { - new_trusted_calculation( - "preview_value", - "Preview an allowed column with a bound value.", - "SELECT {{column}}, {{value}} AS bound_value FROM sales LIMIT 1", - arguments = list( - new_typed_argument( - "column", - "string", - identifier = TRUE, - choices = c("region", "revenue") - ), - new_typed_argument("value", "number") - ) - ) -} - test_that("trusted calculations bind values and allowlist identifiers", { calculation <- test_calculation() prepared <- prepare_calculation( diff --git a/pkg-r/tests/testthat/test-commons.R b/pkg-r/tests/testthat/test-commons.R index 79030b8da..00a992913 100644 --- a/pkg-r/tests/testthat/test-commons.R +++ b/pkg-r/tests/testthat/test-commons.R @@ -44,6 +44,61 @@ test_that("$run_sql() loads pending pins", { expect_equal(tr$run_sql("SELECT * FROM orders"), sales[1:2, ]) }) +test_that("$calculation() runs trusted calculations", { + source <- test_source() + state <- data_source_state(source) + state$calculations <- list(test_calculation()) + tr <- commons(data_sources = list(sales_db = source)) + + result <- tr$calculation( + "preview_value", + arguments = list(column = "region", value = 2) + ) + + expect_s3_class(result, "data.frame") + expect_named(result, c("region", "bound_value")) + expect_equal(result$bound_value, 2) + expect_error( + tr$calculation( + "preview_value", + arguments = list(column = "secret", value = 2) + ), + "does not allow" + ) +}) + +test_that("$context() searches a context layer", { + # requires duckdb extensions via ragnar (#400) + skip_on_cran() + withr::local_options(commons.context_cache = withr::local_tempdir()) + path <- withr::local_tempfile(fileext = ".md") + writeLines(c("# Revenue", "", "Revenue means booked revenue."), path) + tr <- commons( + data_sources = list(sales_db = test_source()), + context_layer = context_layer(files = path) + ) + + expect_match(tr$context("what counts as revenue?"), "booked revenue") +}) + +test_that("$context() searches data dictionaries without a context layer", { + # requires duckdb extensions via ragnar (#400) + skip_on_cran() + withr::local_options(commons.context_cache = withr::local_tempdir()) + tr <- commons(data_sources = list(sales_db = definitions_source())) + + expect_match(tr$context("what is one row of sales?"), "One row per order line") +}) + +test_that("$describe() describes a table's columns", { + tr <- commons(data_sources = list(sales_db = test_source())) + description <- tr$describe("sales") + + for (column in names(test_sales())) { + expect_match(description, column, fixed = TRUE) + } +}) + test_that("agent() builds an agent around a commons() object", { withr::local_options(commons.allow_unsafe_fallback = TRUE) tr <- commons(data_sources = list(sales_db = test_source()))