diff --git a/.Rbuildignore b/.Rbuildignore
index f2e54628..7fcfcc14 100644
--- a/.Rbuildignore
+++ b/.Rbuildignore
@@ -5,6 +5,7 @@
^README\.Rmd$
^cran-comments\.md$
inst/resources
+^inst/examples$
inst/slides/
inst/tiles/
plans/
diff --git a/DESCRIPTION b/DESCRIPTION
index d93692f3..1135caa1 100644
--- a/DESCRIPTION
+++ b/DESCRIPTION
@@ -36,6 +36,7 @@ Imports:
rlang (>= 1.2.0),
roxygen2,
S7,
+ shinychat (> 0.4.0),
utils
Suggests:
bit64,
@@ -44,6 +45,8 @@ Suggests:
chromote,
dbplyr,
dplyr,
+ ggplot2,
+ gt,
htmltools,
odbc,
otel (>= 0.2.0),
@@ -55,7 +58,6 @@ Suggests:
readr,
rmarkdown,
shiny (>= 1.11.1),
- shinychat (> 0.4.0),
shinytest2,
testthat (>= 3.0.0),
vitals,
diff --git a/R/gt-output.R b/R/gt-output.R
new file mode 100644
index 00000000..3e7c44f9
--- /dev/null
+++ b/R/gt-output.R
@@ -0,0 +1,22 @@
+is_gt_table <- function(x) {
+ inherits(x, "gt_tbl")
+}
+
+recover_gt_table_data <- function(value, call = rlang::caller_env()) {
+ data <- value[["_data"]]
+ if (!is.data.frame(data)) {
+ cli::cli_abort(
+ "Can't recover data from the {.cls gt_tbl} returned by the measure.",
+ call = call
+ )
+ }
+ as.data.frame(data)
+}
+
+render_gt_table <- function(value) {
+ rendered <- htmltools::renderTags(htmltools::as.tags(value))
+ list(
+ html = as.character(rendered$html),
+ dependencies = rendered$dependencies
+ )
+}
diff --git a/R/measures.R b/R/measures.R
index 0c9e8712..e0cc2904 100644
--- a/R/measures.R
+++ b/R/measures.R
@@ -118,6 +118,18 @@ expand_measures <- function(args, env = rlang::caller_env()) {
#' body is ordinary R; its `arguments` schema tells the model what inputs it can
#' supply.
#'
+#' Two return types receive special display handling: ggplots and [gt::gt()]
+#' tables are shown directly to the user in the opened measure result. The model
+#' is told that the plot or table has already been shown, so it can interpret the
+#' result without repeating it.
+#'
+#' For full control over a result, `fn` can return an
+#' [ellmer::ContentToolResult]. Its `value` is sent to the model and its
+#' `extra$display` controls the shinychat display. When the display includes
+#' HTML, Markdown, or text, the model is told that the result is already visible
+#' to the user. An optional `extra$data` value is made available to `run_r` and
+#' removed from the result before it is returned to ellmer.
+#'
#' @param name Measure name.
#' @param description What the measure computes.
#' @param fn Function that computes the measure.
@@ -130,6 +142,27 @@ expand_measures <- function(args, env = rlang::caller_env()) {
#'
#' @return A measure object.
#'
+#' @examples
+#' table <- data.frame(term = c("Headache", "Nausea"), count = c(7, 5))
+#' table_measure <- measure(
+#' "adverse_events",
+#' "Summarize adverse events.",
+#' function() {
+#' ellmer::ContentToolResult(
+#' value = "Headache: 7; Nausea: 5",
+#' extra = list(
+#' display = shinychat::tool_result_display(
+#' html = paste0(
+#' "
"
+#' )
+#' ),
+#' data = table
+#' )
+#' )
+#' }
+#' )
+#'
#' @seealso [semantic_layer()] to collect measures into a layer.
#'
#' @export
diff --git a/R/plot-output.R b/R/plot-output.R
new file mode 100644
index 00000000..1d17a64c
--- /dev/null
+++ b/R/plot-output.R
@@ -0,0 +1,52 @@
+is_ggplot <- function(x) {
+ inherits(x, "ggplot")
+}
+
+render_plot_image <- function(plot, alt) {
+ dims <- plot_dimensions()
+ path <- tempfile("commons-plot-", fileext = ".png")
+ on.exit(unlink(path), add = TRUE)
+ render_plot_png(plot, path, dims$width, dims$height)
+ model <- ellmer::content_image_file(path, resize = "none")
+ list(
+ model = model,
+ html = sprintf(
+ paste0(
+ "
"
+ ),
+ model@data,
+ html_escape(alt)
+ )
+ )
+}
+
+plot_dimensions <- function() {
+ list(width = 768L, height = 512L)
+}
+
+render_plot_png <- function(
+ plot,
+ path,
+ width,
+ height,
+ call = rlang::caller_env()
+) {
+ if (requireNamespace("ragg", quietly = TRUE)) {
+ ragg::agg_png(path, width = width, height = height, scaling = 1.5)
+ } else {
+ grDevices::png(path, width = width, height = height)
+ }
+ tryCatch(
+ print(plot),
+ finally = grDevices::dev.off()
+ )
+
+ size <- file.size(path)
+ if (is.na(size) || size == 0) {
+ cli::cli_abort(
+ "Plot rendering did not produce a PNG image.",
+ call = call
+ )
+ }
+}
diff --git a/R/run-r.R b/R/run-r.R
index 49489371..b2c1044f 100644
--- a/R/run-r.R
+++ b/R/run-r.R
@@ -74,10 +74,7 @@ run_r_tool <- function(worker, handles, code, fn_sources = character()) {
rlang::set_names(new_ids),
function(id) get_handle(handles, id)
)
- dims <- plot_dimensions(
- getOption("commons.run_r_plot_aspect_ratio", "3:2"),
- getOption("commons.run_r_plot_size", 768L)
- )
+ dims <- plot_dimensions()
# callr rebinds a transferred function's environment to the worker's
# global env, so the entry point must be namespace-qualified.
worker$rs$call(
@@ -173,6 +170,12 @@ run_r_value <- function(segments) {
}
flush()
+ if (any(vapply(segments, function(seg) seg$type == "plot", logical(1)))) {
+ out[[length(out) + 1L]] <- ellmer::ContentText(
+ visible_result_note("plot")
+ )
+ }
+
if (length(out) == 0) {
return("(The code ran but produced no output.)")
}
@@ -440,22 +443,6 @@ worker_await <- function(
})
}
-plot_dimensions <- function(ratio, longest_side) {
- parts <- suppressWarnings(
- as.numeric(strsplit(ratio, ":", fixed = TRUE)[[1]])
- )
- r <- if (length(parts) == 2 && all(!is.na(parts) & parts > 0)) {
- parts[[1]] / parts[[2]]
- } else {
- 3 / 2
- }
- if (r >= 1) {
- list(width = as.integer(round(longest_side)), height = as.integer(round(longest_side / r)))
- } else {
- list(width = as.integer(round(longest_side * r)), height = as.integer(round(longest_side)))
- }
-}
-
# --- worker side -------------------------------------------------------------
# Everything below runs inside the callr worker process, whose closure
# environment is reset to the global env, so these reference only base R,
diff --git a/R/tools.R b/R/tools.R
index fef43aaa..46caad55 100644
--- a/R/tools.R
+++ b/R/tools.R
@@ -182,7 +182,9 @@ tool_call_measure <- function(private) {
"Run trusted calculations returned by",
"search_pool. `arguments` is a JSON object using exactly the argument",
"names from search_pool. Prefer a measure's own arguments when they can",
- "answer the question directly."
+ "answer the question directly. Measure results may be displayed directly",
+ "to the user. If a result says it is already visible, do not reproduce",
+ "it in your reply; summarize or interpret the relevant results instead."
),
arguments = list(
name = ellmer::type_string(
@@ -340,7 +342,19 @@ call_measure_tool <- function(
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(td, value, handles))
+ }
value <- collect_lazy_table(value)
+ if (is_ggplot(value)) {
+ advert <- register_handle(handles, value)
+ return(measure_plot_tool_result(td, args, value, advert))
+ }
+ if (is_gt_table(value)) {
+ data <- recover_gt_table_data(value)
+ advert <- register_handle(handles, data)
+ return(measure_gt_table_tool_result(td, args, value, data, advert))
+ }
advert <- register_handle(handles, value)
tool_result(
paste(c(format_measure_value(value), advert), collapse = "\n\n"),
@@ -352,6 +366,196 @@ call_measure_tool <- function(
)
}
+measure_content_tool_result <- function(td, result, handles) {
+ data <- result@extra$data
+ result@extra$data <- NULL
+ display <- result@extra$display
+
+ if (is.null(result@error)) {
+ data <- collect_lazy_table(data)
+ advert <- register_handle(handles, data)
+ result@value <- append_handle_advert(result@value, advert)
+ if (measure_result_is_visible(display)) {
+ result@value <- prepend_model_note(
+ result@value,
+ visible_result_note("measure result")
+ )
+ }
+ }
+
+ title <- sprintf("Measure: %s", tool_title(td))
+ icon <- maybe_icon("shield-check")
+ if (is.null(display)) {
+ display <- shinychat::tool_result_display(title = title, icon = icon)
+ } else if (is.list(display)) {
+ display$title <- display$title %||% title
+ display$icon <- display$icon %||% icon
+ }
+ result@extra$display <- display
+ result@extra$commons_tag <- "A"
+ result
+}
+
+measure_result_is_visible <- function(display) {
+ is.list(display) && any(vapply(
+ display[c("html", "markdown", "text")],
+ Negate(is.null),
+ logical(1)
+ ))
+}
+
+prepend_model_note <- function(value, note) {
+ note <- ellmer::ContentText(note)
+ if (S7::S7_inherits(value, ellmer::Content)) {
+ return(list(note, value))
+ }
+ if (
+ is.list(value) &&
+ length(value) > 0 &&
+ all(vapply(value, S7::S7_inherits, logical(1), ellmer::Content))
+ ) {
+ return(c(list(note), value))
+ }
+ paste(c(note@text, format_measure_value(value)), collapse = "\n\n")
+}
+
+append_handle_advert <- function(value, advert) {
+ if (is.null(advert)) {
+ return(value)
+ }
+ note <- ellmer::ContentText(advert)
+ if (S7::S7_inherits(value, ellmer::Content)) {
+ return(list(value, note))
+ }
+ if (
+ is.list(value) &&
+ length(value) > 0 &&
+ all(vapply(value, S7::S7_inherits, logical(1), ellmer::Content))
+ ) {
+ return(c(value, list(note)))
+ }
+ paste(c(format_measure_value(value), advert), collapse = "\n\n")
+}
+
+measure_plot_tool_result <- function(td, args, value, advert) {
+ title <- tool_title(td)
+ rendered <- tryCatch(
+ render_plot_image(value, sprintf("Plot returned by %s", title)),
+ error = function(error) error
+ )
+ if (inherits(rendered, "error")) {
+ return(measure_failure_result(
+ args,
+ advert,
+ title,
+ conditionMessage(rendered),
+ "a plot",
+ "commons-measure-plot-error"
+ ))
+ }
+
+ model_value <- list(
+ ellmer::ContentText(visible_result_note("plot")),
+ rendered$model
+ )
+ if (!is.null(advert)) {
+ model_value[[length(model_value) + 1L]] <- ellmer::ContentText(advert)
+ }
+ tool_result(
+ model_value,
+ title = sprintf("Measure: %s", html_escape(title)),
+ icon = maybe_icon("shield-check"),
+ html = measure_display_with_result_html(
+ args,
+ measure_result_html(rendered$html)
+ ),
+ tag = "A",
+ open = TRUE,
+ show_tag = FALSE
+ )
+}
+
+measure_failure_result <- function(
+ args,
+ advert,
+ title,
+ message,
+ result_type,
+ class,
+ model_content = NULL
+) {
+ note <- sprintf(
+ "The measure returned %s, but it could not be displayed: %s",
+ result_type,
+ message
+ )
+ tool_result(
+ paste(c(model_content, note, advert), collapse = "\n\n"),
+ title = sprintf("Measure: %s", html_escape(title)),
+ icon = maybe_icon("shield-check"),
+ html = measure_display_with_result_html(
+ args,
+ measure_result_html(html_escape(note), class)
+ ),
+ tag = "A",
+ open = TRUE,
+ show_tag = FALSE
+ )
+}
+
+measure_gt_table_tool_result <- function(td, args, value, data, advert) {
+ title <- tool_title(td)
+ rendered <- tryCatch(
+ render_gt_table(value),
+ error = function(error) error
+ )
+ model_content <- df_to_markdown(data)
+ if (inherits(rendered, "error")) {
+ return(measure_failure_result(
+ args,
+ advert,
+ title,
+ conditionMessage(rendered),
+ "a gt table",
+ "commons-measure-gt-table-error",
+ model_content = model_content
+ ))
+ }
+ model_note <- c(
+ visible_result_note("gt table"),
+ if (!is.null(advert)) {
+ paste(
+ "For calculations, use `run_r` with the table handle below instead of",
+ "parsing values from the rendered table."
+ )
+ }
+ )
+ model_note <- paste(model_note, collapse = " ")
+ display_html <- measure_display_with_result_html(
+ args,
+ measure_result_html(rendered$html, "commons-measure-gt-table")
+ )
+ if (length(rendered$dependencies) > 0) {
+ display_html <- htmltools::attachDependencies(
+ htmltools::HTML(display_html),
+ rendered$dependencies,
+ append = TRUE
+ )
+ }
+ tool_result(
+ paste(
+ c(model_note, model_content, advert),
+ collapse = "\n\n"
+ ),
+ title = sprintf("Measure: %s", html_escape(title)),
+ icon = maybe_icon("shield-check"),
+ html = display_html,
+ tag = "A",
+ open = TRUE,
+ show_tag = FALSE
+ )
+}
+
search_context_tool <- function(context, query) {
if (is.null(context)) {
return("No context layer is configured for this agent.")
@@ -532,6 +736,13 @@ tool_result <- function(
)
}
+visible_result_note <- function(type) {
+ paste(
+ sprintf("This %s is now visible to the user.", type),
+ "**Do not recreate or repeat it**."
+ )
+}
+
tag_label <- function(tag) {
switch(tag, A = "Registered measure (A)", B = "SQL query (B)", tag)
}
@@ -589,17 +800,29 @@ measure_args_html <- function(args) {
}
measure_display_html <- function(args, value) {
+ measure_display_with_result_html(
+ args,
+ measure_result_html(format_measure_html(value))
+ )
+}
+
+measure_display_with_result_html <- function(args, result_html) {
sprintf(
"%s%s
",
measure_args_html(args),
- measure_result_html(value)
+ result_html
)
}
-measure_result_html <- function(value) {
+measure_result_html <- function(content, class = NULL) {
+ class <- if (is.null(class)) "" else paste0(" ", class)
sprintf(
- "",
- format_measure_html(value)
+ paste0(
+ ""
+ ),
+ class,
+ content
)
}
diff --git a/inst/examples/plot-app/app.R b/inst/examples/plot-app/app.R
new file mode 100644
index 00000000..5997de5c
--- /dev/null
+++ b/inst/examples/plot-app/app.R
@@ -0,0 +1,46 @@
+# Run from the repository root with:
+# shiny::runApp("inst/examples/plot-app")
+
+library(bslib)
+library(ggplot2)
+library(shiny)
+
+devtools::load_all("../../..")
+
+values <- data.frame(
+ group = c("A", "B", "C"),
+ value = c(8, 13, 5)
+)
+
+measures <- semantic_layer(
+ measure(
+ "value_plot",
+ "Plot the value for each group.",
+ function() {
+ ggplot(values, aes(group, value, fill = group)) +
+ geom_col(show.legend = FALSE) +
+ labs(x = NULL, y = "Value")
+ },
+ title = "Values by group"
+ )
+)
+
+ui <- page_fillable(
+ theme = bs_theme(version = 5),
+ commons_ui(
+ "chat",
+ greeting = "Try: Show the values by group."
+ )
+)
+
+server <- function(input, output, session) {
+ agent <- commons(
+ ellmer::chat_anthropic(),
+ data_sources = data_source(values = values),
+ semantic_layer = measures
+ )
+
+ commons_server("chat", agent)
+}
+
+shinyApp(ui, server)
diff --git a/inst/www/commons-chat/commons-chat.css b/inst/www/commons-chat/commons-chat.css
index 55d022bc..1bd7119a 100644
--- a/inst/www/commons-chat/commons-chat.css
+++ b/inst/www/commons-chat/commons-chat.css
@@ -235,26 +235,47 @@ shiny-chat-container
white-space: pre-wrap;
}
-.commons-measure-result-value table {
+.commons-measure-result-value > table {
border-collapse: collapse;
font-size: 0.85rem;
width: 100%;
}
-.commons-measure-result-value th,
-.commons-measure-result-value td {
+.commons-measure-result-value > table th,
+.commons-measure-result-value > table td {
border-bottom: 1px solid var(--bs-border-color, #dee2e6);
padding: 0.3rem 0.5rem;
}
-.commons-measure-result-value th {
+.commons-measure-result-value > table th {
font-weight: 600;
}
-.commons-measure-result-value tbody tr:nth-child(even) {
+.commons-measure-result-value > table tbody tr:nth-child(even) {
background: var(--bs-tertiary-bg, #f3f4f6);
}
+.commons-measure-gt-table {
+ max-width: 100%;
+ overflow-x: auto;
+ white-space: normal;
+}
+
+.commons-measure-gt-table-error,
+.commons-measure-plot-error {
+ color: var(--bs-secondary-color, #6c757d);
+ white-space: normal;
+}
+
+.commons-measure-plot {
+ border: 1px solid var(--bs-border-color, #dee2e6);
+ border-radius: 0.5rem;
+ display: block;
+ height: auto;
+ max-width: min(100%, 34rem);
+ white-space: normal;
+}
+
/* ---- run_r display ---------------------------------------------------- */
.commons-run-r-display {
diff --git a/man/measure.Rd b/man/measure.Rd
index 3630a4ff..b40f3ce1 100644
--- a/man/measure.Rd
+++ b/man/measure.Rd
@@ -28,6 +28,41 @@ A measure object.
A measure is a governed calculation inside a \code{\link[=semantic_layer]{semantic_layer()}}. Its function
body is ordinary R; its \code{arguments} schema tells the model what inputs it can
supply.
+}
+\details{
+Two return types receive special display handling: ggplots and \code{\link[gt:gt]{gt::gt()}}
+tables are shown directly to the user in the opened measure result. The model
+is told that the plot or table has already been shown, so it can interpret the
+result without repeating it.
+
+For full control over a result, \code{fn} can return an
+\link[ellmer:ContentToolResult]{ellmer::ContentToolResult}. Its \code{value} is sent to the model and its
+\code{extra$display} controls the shinychat display. When the display includes
+HTML, Markdown, or text, the model is told that the result is already visible
+to the user. An optional \code{extra$data} value is made available to \code{run_r} and
+removed from the result before it is returned to ellmer.
+}
+\examples{
+table <- data.frame(term = c("Headache", "Nausea"), count = c(7, 5))
+table_measure <- measure(
+ "adverse_events",
+ "Summarize adverse events.",
+ function() {
+ ellmer::ContentToolResult(
+ value = "Headache: 7; Nausea: 5",
+ extra = list(
+ display = shinychat::tool_result_display(
+ html = paste0(
+ ""
+ )
+ ),
+ data = table
+ )
+ )
+ }
+)
+
}
\seealso{
\code{\link[=semantic_layer]{semantic_layer()}} to collect measures into a layer.
diff --git a/tests/testthat/test-commons.R b/tests/testthat/test-commons.R
index bb797bde..4c024465 100644
--- a/tests/testthat/test-commons.R
+++ b/tests/testthat/test-commons.R
@@ -58,6 +58,16 @@ test_that("run_r describes which results are visible to the user", {
expect_match(description, "user cannot run code in this session")
})
+test_that("call_measure describes how to handle visible results", {
+ agent <- test_agent(
+ semantic_layer = semantic_layer(count_measure_tool())
+ )
+ description <- tool_description(agent_tool(agent, "call_measure"))
+
+ expect_match(description, "results may be displayed directly")
+ expect_match(description, "do not reproduce it in your reply")
+})
+
test_that("the system prompt includes tables and the date", {
agent <- test_agent(
semantic_layer = semantic_layer(
diff --git a/tests/testthat/test-run-r.R b/tests/testthat/test-run-r.R
index 8a3cfa50..f7aaf4e0 100644
--- a/tests/testthat/test-run-r.R
+++ b/tests/testthat/test-run-r.R
@@ -52,12 +52,21 @@ test_that("run_r returns plots as images and opens the display", {
res <- sync_promise(run_r_tool(worker, store, "plot(r1$revenue)"))
+ images <- Filter(
+ \(x) S7::S7_inherits(x, ellmer::ContentImageInline),
+ res@value
+ )
+ expect_length(images, 1)
+ notes <- Filter(
+ \(x) S7::S7_inherits(x, ellmer::ContentText),
+ res@value
+ )
expect_true(any(vapply(
- res@value,
- function(x) S7::S7_inherits(x, ellmer::ContentImageInline),
+ notes,
+ \(x) grepl("This plot is already visible to the user", x@text, fixed = TRUE),
logical(1)
)))
- expect_true(res@extra$display$open)
+ expect_identical(res@extra$display$open, TRUE)
expect_match(res@extra$display$html, "data:image/png;base64,")
expect_match(res@extra$display$html, "commons-run-r-details")
})
diff --git a/tests/testthat/test-tools.R b/tests/testthat/test-tools.R
index e21a43f6..971a212f 100644
--- a/tests/testthat/test-tools.R
+++ b/tests/testthat/test-tools.R
@@ -66,6 +66,235 @@ test_that("call_measure_tool registers scalar output as a handle", {
expect_match(res@value, "Available to `run_r` as `r1`", fixed = TRUE)
expect_equal(get_handle(store, "r1"), 6L)
+ expect_identical(res@extra$display$open, FALSE)
+})
+
+test_that("call_measure_tool supports custom ContentToolResult values", {
+ table <- data.frame(term = "Headache", count = 7)
+ display <- shinychat::tool_result_display(
+ html = htmltools::tags$table(
+ htmltools::tags$tr(
+ htmltools::tags$td("Headache"),
+ htmltools::tags$td("7")
+ )
+ ),
+ open = TRUE
+ )
+ registry <- list(
+ table = measure(
+ "table",
+ "Summarize adverse events.",
+ function() {
+ ellmer::ContentToolResult(
+ value = "Headache: 7",
+ extra = list(
+ display = display,
+ data = table,
+ custom = "preserved"
+ )
+ )
+ },
+ title = "Adverse events & outcomes"
+ )
+ )
+ store <- new_handle_store()
+
+ res <- call_measure_tool(registry, "table", "{}", handles = store)
+
+ expect_match(
+ res@value,
+ "This measure result is already visible to the user",
+ fixed = TRUE
+ )
+ expect_match(res@value, "Do not recreate or repeat it", fixed = TRUE)
+ expect_match(res@value, "Headache: 7", fixed = TRUE)
+ expect_match(res@value, "Available to `run_r` as `r1`", fixed = TRUE)
+ expect_identical(get_handle(store, "r1"), table)
+ expect_null(res@extra$data)
+ expect_identical(res@extra$custom, "preserved")
+ expect_identical(res@extra$display$html, display$html)
+ expect_identical(res@extra$display$open, TRUE)
+ expect_identical(
+ res@extra$display$title,
+ "Measure: Adverse events & outcomes"
+ )
+ expect_equal(res@extra$commons_tag, "A")
+})
+
+test_that("call_measure_tool preserves image content in ContentToolResult", {
+ image <- ellmer::ContentImageInline("image/png", "YWJj")
+ data <- data.frame(x = 1)
+ registry <- list(
+ image = measure(
+ "image",
+ "Return an image.",
+ function() {
+ ellmer::ContentToolResult(
+ value = image,
+ extra = list(data = data)
+ )
+ }
+ )
+ )
+ store <- new_handle_store()
+
+ res <- call_measure_tool(registry, "image", "{}", handles = store)
+
+ expect_length(res@value, 2)
+ expect_identical(res@value[[1]], image)
+ expect_s7_class(res@value[[2]], ellmer::ContentText)
+ expect_match(
+ res@value[[2]]@text,
+ "Available to `run_r` as `r1`",
+ fixed = TRUE
+ )
+ expect_identical(get_handle(store, "r1"), data)
+ expect_null(res@extra$data)
+ expect_identical(res@extra$display$title, "Measure: image")
+})
+
+test_that("call_measure_tool preserves custom ContentToolResult errors", {
+ registry <- list(
+ error = measure(
+ "error",
+ "Return an authored error.",
+ function() {
+ ellmer::ContentToolResult(
+ error = "authored error",
+ extra = list(data = structure(list(), class = "tbl_sql"))
+ )
+ }
+ )
+ )
+ store <- new_handle_store()
+
+ res <- call_measure_tool(registry, "error", "{}", handles = store)
+
+ expect_identical(res@error, "authored error")
+ expect_null(res@extra$data)
+ expect_length(handle_ids(store), 0)
+})
+
+test_that("call_measure_tool shows ggplot results to the model and user", {
+ skip_if_not_installed("ggplot2")
+ plot <- ggplot2::ggplot()
+ registry <- list(
+ plot = measure(
+ "plot",
+ "Plot values.",
+ function() plot,
+ title = 'A & "B"'
+ )
+ )
+ store <- new_handle_store()
+
+ res <- call_measure_tool(registry, "plot", "{}", handles = store)
+
+ images <- Filter(
+ \(x) S7::S7_inherits(x, ellmer::ContentImageInline),
+ res@value
+ )
+
+ expect_length(images, 1)
+ notes <- Filter(
+ \(x) S7::S7_inherits(x, ellmer::ContentText),
+ res@value
+ )
+ expect_true(any(vapply(
+ notes,
+ \(x) grepl("This plot is already visible to the user", x@text, fixed = TRUE),
+ logical(1)
+ )))
+ expect_match(
+ res@extra$display$html,
+ 'alt="Plot returned by A & "B""',
+ fixed = TRUE
+ )
+ expect_s3_class(get_handle(store, "r1"), "ggplot")
+ expect_identical(res@extra$display$open, TRUE)
+})
+
+test_that("call_measure_tool shows gt tables to the model and user", {
+ skip_if_not_installed("gt")
+ table_data <- data.frame(term = "Headache", count = 7)
+ table <- gt::opt_interactive(gt::gt(table_data))
+ registry <- list(
+ table = measure(
+ "table",
+ "Summarize adverse events.",
+ function() table
+ )
+ )
+ store <- new_handle_store()
+
+ res <- call_measure_tool(registry, "table", "{}", handles = store)
+
+ expect_match(
+ res@value,
+ "This gt table is already visible to the user",
+ fixed = TRUE
+ )
+ expect_match(
+ res@value,
+ "For calculations, use `run_r` with the table handle below",
+ fixed = TRUE
+ )
+ expect_match(res@value, "Headache", fixed = TRUE)
+ expect_no_match(res@value, "", fixed = TRUE)
+ expect_match(res@extra$display$html, "Headache", fixed = TRUE)
+ expect_gt(length(htmltools::findDependencies(res@extra$display$html)), 0)
+ expect_identical(get_handle(store, "r1"), table_data)
+ expect_identical(res@extra$display$open, TRUE)
+})
+
+test_that("call_measure_tool keeps recoverable table data when HTML conversion fails", {
+ skip_if_not_installed("gt")
+ table_data <- data.frame(term = "Headache", count = 7)
+ table <- gt::gt(table_data)
+ local_mocked_bindings(
+ render_gt_table = function(...) stop("HTML conversion broke")
+ )
+ registry <- list(
+ table = measure(
+ "table",
+ "Summarize adverse events.",
+ function() table
+ )
+ )
+ store <- new_handle_store()
+
+ res <- call_measure_tool(registry, "table", "{}", handles = store)
+
+ expect_match(res@value, "Headache", fixed = TRUE)
+ expect_match(
+ res@extra$display$html,
+ "commons-measure-gt-table-error",
+ fixed = TRUE
+ )
+ expect_identical(get_handle(store, "r1"), table_data)
+ expect_identical(res@extra$display$open, TRUE)
+})
+
+test_that("call_measure_tool keeps ggplot results when display rendering fails", {
+ skip_if_not_installed("ggplot2")
+ local_mocked_bindings(
+ render_plot_image = function(...) stop("graphics device broke")
+ )
+ plot <- ggplot2::ggplot()
+ registry <- list(
+ plot = measure(
+ "plot",
+ "Plot values.",
+ function() plot
+ )
+ )
+ store <- new_handle_store()
+
+ res <- call_measure_tool(registry, "plot", "{}", handles = store)
+
+ expect_match(res@value, "could not be displayed")
+ expect_match(res@extra$display$html, "commons-measure-plot-error")
+ expect_s3_class(get_handle(store, "r1"), "ggplot")
})
test_that("register_handle numbers handles in call order and caps rows", {