From a9dae0a0ad7bf459b50d0b05b9bdd00231a1f513 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 7 May 2025 12:58:21 -0400 Subject: [PATCH 01/34] Ignore _dev folder --- .Rbuildignore | 1 + .gitignore | 1 + 2 files changed, 2 insertions(+) diff --git a/.Rbuildignore b/.Rbuildignore index af0ed86c..d36604f3 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -14,3 +14,4 @@ _cache/ ^[\.]?air\.toml$ ^\.vscode$ ^data-raw$ +^_dev$ diff --git a/.gitignore b/.gitignore index 0160fa21..b46d1001 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ docs inst/doc /.quarto/ +_dev/ From 8c0ccb9ff0de2deceddb6ca360764f29826ae370 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 15 May 2025 10:33:37 -0400 Subject: [PATCH 02/34] Init pass at contents_record / contents_replay Need to remove with_chat and instead use as argument. Avoids temp global state state / clearly provides context --- DESCRIPTION | 3 +- NAMESPACE | 3 + R/content-replay.R | 386 +++++++++++++++++++++++++++ R/tools-def.R | 1 + man/chat_cloudflare.Rd | 1 + man/chat_huggingface.Rd | 1 + man/contents_record.Rd | 24 ++ tests/testthat/test-content-replay.R | 124 +++++++++ 8 files changed, 542 insertions(+), 1 deletion(-) create mode 100644 R/content-replay.R create mode 100644 man/contents_record.Rd create mode 100644 tests/testthat/test-content-replay.R diff --git a/DESCRIPTION b/DESCRIPTION index 61aacd97..94439773 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -59,6 +59,7 @@ RoxygenNote: 7.3.2 Collate: 'utils-S7.R' 'types.R' + 'ellmer-package.R' 'tools-def.R' 'content.R' 'provider.R' @@ -69,9 +70,9 @@ Collate: 'content-image.R' 'content-pdf.R' 'turns.R' + 'content-replay.R' 'content-tools.R' 'deprecated.R' - 'ellmer-package.R' 'httr2.R' 'import-standalone-obj-type.R' 'import-standalone-purrr.R' diff --git a/NAMESPACE b/NAMESPACE index d241e54f..95774282 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -51,6 +51,9 @@ export(content_pdf_file) export(content_pdf_url) export(contents_html) export(contents_markdown) +export(contents_record) +export(contents_replay) +export(contents_replay_s7) export(contents_text) export(create_tool_def) export(google_upload) diff --git a/R/content-replay.R b/R/content-replay.R new file mode 100644 index 00000000..794fde42 --- /dev/null +++ b/R/content-replay.R @@ -0,0 +1,386 @@ +#' @include utils-S7.R +#' @include turns.R +#' @include tools-def.R +#' @include content.R + +NULL + +#' Save and restore content +#' +#' @description +#' These generic functions can be use to convert [Turn] contents or [Content] +#' objects into easily serializable representations. +#' +#' * `contents_replay()` will accept a basic object and return a corresponding +#' [Turn] or [Content] object. +#' * `contents_record()` will accept a [Turn] or [Content] object and return a +#' basic object that can be easily serialized. +#' @export +contents_record <- new_generic("contents_record", "content") +# #' @export +# contents_record_prop_names <- new_generic( +# "contents_record_prop_names", +# "content" +# ) + +# contents_replay_impl <- new_generic( +# "contents_replay_impl", +# "type", +# function(state, type) { +# S7::S7_dispatch() +# } +# ) + +# method(contents_record, Turn) <- function(content) { +# list( +# version = 1, +# type = "turn", +# props = list( +# role = content@role, +# contents = contents_record(content@contents), +# tokens = content@tokens, +# completed = content@completed +# ) +# ) +# } +method(contents_record, S7::S7_object) <- function(content) { + prop_names <- S7::prop_names(content) + list( + version = 1, + class = class(content)[1], + props = setNames( + lapply(prop_names, function(prop_name) { + prop_value <- S7::prop(prop_name, object = content) + if (S7_inherits(prop_value)) { + contents_record(prop_value) + } else { + prop_value + } + }), + prop_names + ) + ) +} +method(contents_record, Turn) <- function(content) { + list( + version = 1, + class = class(content)[1], + props = list( + role = content@role, + contents = lapply(content@contents, contents_record), + json = content@json, + tokens = content@tokens, + completed = content@completed + ) + ) +} + +#' @rdname contents_record +#' @export +contents_replay <- function(obj) { + # Find any reason to not believe `obj` is a recorded object. + # If not a recorded object, return it as is. + # If it is a recorded s7 object, dispatch on the discovered class. + + if (!is.list(obj)) { + return(obj) + # cli::cli_abort( + # "Expected a list, but got {.val {obj}}.", + # call = caller_env() + # ) + } + if (!all(c("version", "class", "props") %in% names(obj))) { + return(obj) + # cli::cli_abort( + # "Expected a list with version, class, and props keys, but got {.val {obj}}.", + # call = caller_env() + # ) + } + class_value <- obj$class + if (!(is.character(class_value) && length(class_value) > 0)) { + return(obj) + # cli::cli_abort( + # "class key must be a string. {.val {class_value}}.", + # call = caller_env() + # ) + } + pkg_cls <- strsplit(class_value[1], "::")[[1]] + if (length(pkg_cls) != 2) { + return(obj) + # cli::cli_abort( + # "class key must be a string with a package name. {.val {class_value}}.", + # call = caller_env() + # ) + } + pkg_name <- pkg_cls[1] + cls_name <- pkg_cls[2] + + cls <- rlang::pkg_env(pkg_name)[[cls_name]] + + if (is.null(cls)) { + return(obj) + # cli::cli_abort( + # "class key must be a valid class name. {.val {class_value}}.", + # call = caller_env() + # ) + } + + if (!S7_inherits(cls)) { + return(obj) + # cli::cli_abort( + # "class key must be a S7 class. {.val {class_value}}.", + # call = caller_env() + # ) + } + + # Manually retrieve the handler for the class as we dispatch on the class itself, + # not on an instance + # An error will be thrown if a method is not found, + # however we have a fallback for the `S7::S7_object` (the root base class) + handler <- S7::method(contents_replay_s7, cls) + handler(cls, obj) +} + +#' @rdname contents_record +#' @export +contents_replay_s7 <- new_generic( + "contents_replay_s7", + "cls", + function(cls, obj) { + S7::S7_dispatch() + } +) + + +method(contents_replay_s7, S7::S7_object) <- function(cls, obj) { + stopifnot(obj$version == 1) + + obj_props <- lapply(obj$props, contents_replay) + ## While this should give prettier tracebacks, it doesn't work + # > cls_name <- rlang::sym(obj$class[1]) + # > rlang::inject((!!cls_name)(!!!obj_props)) + # Error in `ellmer::Turn`(role = "user", contents = list(), json = list(), : + # could not find function "ellmer::Turn" + + print(cls) + + rlang::inject(cls(!!!obj_props)) +} + +with_chat_env <- list2env(list()) +with_chat_set <- function(chat) { + if (is.null(chat)) { + with_chat_env$chat <- NULL + return() + } + if (!inherits(chat, "Chat")) { + cli::cli_abort( + "Expected a Chat object, but got {.val {chat}}.", + call = caller_env() + ) + } + with_chat_env$chat <- chat +} +with_chat_get <- function() { + chat <- with_chat_env$chat + if (is.null(chat)) { + cli::cli_abort( + "No Chat object found in the environment.", + call = caller_env() + ) + } + chat +} +with_chat <- function(chat, code) { + with_chat_set(chat) + on.exit(with_chat_set(NULL), add = TRUE) + force(code) +} + +method(contents_record, ToolDef) <- function(content) { + list( + version = 1, + class = class(content)[1], + props = list( + name = content@name + # description = content$description, + # arguments = content$arguments, + # annotations = content$annotations + ) + ) +} +method(contents_replay_s7, ToolDef) <- function(cls, obj) { + if (obj$version != 1) { + cli::cli_abort( + "Unsupported version {.val {obj$version}}.", + call = caller_env() + ) + } + chat <- with_chat_get() + tools <- chat$get_tools() + + tool <- tools[[obj$props$name]] + # Return matched tool or NULL + return(tool) +} + +tool_rnorm <- tool( + stats::rnorm, + "Drawn numbers from a random normal distribution", + n = type_integer("The number of observations. Must be a positive integer."), + mean = type_number("The mean value of the distribution."), + sd = type_number( + "The standard deviation of the distribution. Must be a non-negative number." + ) +) + +# method(contents_replay_impl, "S7") <- function(state) { +# if (!is.list(state)) { +# cli::cli_abort( +# "Expected a list, but got {.val {state}}.", +# call = caller_env() +# ) +# } + +# contents <- lapply(prop_names, function(prop_name) { +# prop_value <- S7::prop(prop_name, object = content) +# contents_replay(prop_value) +# }) +# contents <- unlist(contents, recursive = FALSE) +# contents <- contents[!sapply(contents, is.null)] +# contents <- contents[contents != ""] +# contents +# } + +# contents_list_replay <- function(state) { +# } + +# contents_record_old <- new_generic("contents_record_old", "content") +# method(contents_record_old_prop_names, S7_object) <- function(content) { +# S7::prop_names(content) +# } + +# method(contents_record_old, S7_object) <- function(content) { +# prop_names <- contents_record_old_prop_names(content) +# if (length(prop_names) == 0) { +# return(NULL) +# } +# props <- lapply(prop_names, function(prop_name) { +# prop_value <- S7::prop(prop_name, object = content) +# contents_record_old(prop_value) +# }) +# props <- setNames(props, prop_names) +# list( +# version = 1, +# type = "s7", +# class = class(content), +# props = props +# ) +# } + +# method(contents_record_old, S7::class_list) <- function(content) { +# lapply(content, contents_record_old) +# } +# method(contents_record_old, S7::class_any) <- function(content) { +# content +# } + +# contents_replay_s7_cls <- function(state) { +# if (is.null(state)) { +# return(NULL) +# } +# if (state$version != 1) { +# cli::cli_abort( +# "Unsupported version {.val {state$version}}.", +# call = caller_env() +# ) +# } + +# if (!identical(state$type, "s7")) { +# cli::cli_abort( +# "Unsupported type {.val {state$type}}.", +# call = caller_env() +# ) +# } + +# class_value <- state$class +# if (is.null(class_value)) { +# cli::cli_abort( +# "class key must be provided. {.val {class_value}}." +# ) +# } +# if (!is.character(class_value)) { +# cli::cli_abort( +# "class key must be a string. {.val {class_value}}." +# ) +# } +# if (length(class_value) == 0) { +# cli::cli_abort( +# "class key must be a single string. {.val {class_value}}." +# ) +# } +# if (nchar(class_value[0]) == 0) { +# cli::cli_abort( +# "class key must be a non-empty string. {.val {class_value}}." +# ) +# } + +# s7_cls_info <- strsplit(class_value[0], "::")[[1]] +# s7_cls_pkg <- s7_cls_info[1] +# s7_cls_name <- s7_cls_info[2] +# s7_cls <- rlang::pkg_env(s7_cls_pkg)[[s7_cls_name]] + +# contents_replay(s7_cls, state) +# # s7_cls +# } + +# method(contents_replay, S7::class_list) <- function(state) { +# lapply(state, contents_replay) +# } +# method(contents_replay, S7::class_any) <- function(cls, state) { +# state +# } + +# method(contents_replay, S7_object) <- function(state, type) { +# prop_names <- contents_record_prop_names(content) +# if (length(prop_names) == 0) { +# return(NULL) +# } + +# props <- lapply(prop_names, function(prop_name) { +# prop_value <- S7::prop(prop_name, object = content) +# contents_replay(prop_value) +# }) + +# rlang::inject(cls(!!!state$props)) +# } + +# method(contents_replay, Turn) <- function(object) { +# if (object$version != 1) { +# cli::cli_abort( +# "Unsupported version {.val {object$version}}.", +# call = caller_env() +# ) +# } +# if (object$type != "turn") { +# cli::cli_abort( +# "Unsupported type {.val {object$type}}.", +# call = caller_env() +# ) +# } +# contents <- lapply(content@contents, contents_replay) +# contents <- unlist(contents, recursive = FALSE) +# contents <- contents[!sapply(contents, is.null)] +# contents <- contents[contents != ""] +# contents +# } +# method(contents_replay, S7_object) <- function(content) { +# contents <- lapply(prop_names, function(prop_name) { +# prop_value <- S7::prop(prop_name, object = content) +# contents_replay(prop_value) +# }) +# contents <- unlist(contents, recursive = FALSE) +# contents <- contents[!sapply(contents, is.null)] +# contents <- contents[contents != ""] +# contents +# } diff --git a/R/tools-def.R b/R/tools-def.R index 2257bea9..920af1c3 100644 --- a/R/tools-def.R +++ b/R/tools-def.R @@ -1,5 +1,6 @@ #' @include utils-S7.R #' @include types.R +#' @include ellmer-package.R NULL #' Define a tool diff --git a/man/chat_cloudflare.Rd b/man/chat_cloudflare.Rd index a177b77f..fe9ecee7 100644 --- a/man/chat_cloudflare.Rd +++ b/man/chat_cloudflare.Rd @@ -76,6 +76,7 @@ Other chatbots: \code{\link{chat_github}()}, \code{\link{chat_google_gemini}()}, \code{\link{chat_groq}()}, +\code{\link{chat_huggingface}()}, \code{\link{chat_mistral}()}, \code{\link{chat_ollama}()}, \code{\link{chat_openai}()}, diff --git a/man/chat_huggingface.Rd b/man/chat_huggingface.Rd index 592ca721..84db40c1 100644 --- a/man/chat_huggingface.Rd +++ b/man/chat_huggingface.Rd @@ -78,6 +78,7 @@ Other chatbots: \code{\link{chat_anthropic}()}, \code{\link{chat_aws_bedrock}()}, \code{\link{chat_azure_openai}()}, +\code{\link{chat_cloudflare}()}, \code{\link{chat_cortex_analyst}()}, \code{\link{chat_databricks}()}, \code{\link{chat_deepseek}()}, diff --git a/man/contents_record.Rd b/man/contents_record.Rd new file mode 100644 index 00000000..d43e157e --- /dev/null +++ b/man/contents_record.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/content-replay.R +\name{contents_record} +\alias{contents_record} +\alias{contents_replay} +\alias{contents_replay_s7} +\title{Save and restore content} +\usage{ +contents_record(content, ...) + +contents_replay(obj) + +contents_replay_s7(cls, obj) +} +\description{ +These generic functions can be use to convert \link{Turn} contents or \link{Content} +objects into easily serializable representations. +\itemize{ +\item \code{contents_replay()} will accept a basic object and return a corresponding +\link{Turn} or \link{Content} object. +\item \code{contents_record()} will accept a \link{Turn} or \link{Content} object and return a +basic object that can be easily serialized. +} +} diff --git a/tests/testthat/test-content-replay.R b/tests/testthat/test-content-replay.R new file mode 100644 index 00000000..8c357f97 --- /dev/null +++ b/tests/testthat/test-content-replay.R @@ -0,0 +1,124 @@ +tool_rnorm <- tool( + stats::rnorm, + "Drawn numbers from a random normal distribution", + n = type_integer("The number of observations. Must be a positive integer."), + mean = type_number("The mean value of the distribution."), + sd = type_number( + "The standard deviation of the distribution. Must be a non-negative number." + ) +) + +chat <- chat_ollama(model = "llama3.2") +chat$register_tool(tool_rnorm) + + +expect_record_replay <- function(x) { + # Simulate the full bookmarking experience: + # * Record the object to something serializable + # * Serialize the object to JSON via shiny; "bookmark" + # * Unserialize the object from JSON via shiny; "restore" + # * Replay the unserialized object to the original object + # * Check that the replayed object has the same class as the original object + # * Check that the replayed object has the same properties as the original object + # expect_silent({ + obj <- contents_record(x) + + # Bbookmark + serialized <- shiny:::toJSON(obj) + unserialized <- shiny:::safeFromJSON(serialized) + + replayed <- contents_replay(unserialized) + # }) + + expect_s3_class(replayed, class(x)[1]) + expect_equal(S7::props(replayed), S7::props(x)) +} + +# ------------------------------------------------------------------------- + +test_that("can round trip of Content record/replay", { + expect_record_replay(Content()) +}) + +test_that("can round trip of ContentText record/replay", { + expect_record_replay(ContentText("hello world")) +}) + +test_that("can round trip of ContentImageInline record/replay", { + expect_record_replay( + ContentImageInline("image/png", "abcd123") + ) +}) + +test_that("can round trip of ContentImageRemote record/replay", { + expect_record_replay( + ContentImageRemote("https://example.com/image.jpg", detail = "") + ) +}) + +test_that("can round trip of ContentJson record/replay", { + expect_record_replay( + ContentJson(list(a = 1:2, b = "apple")) + ) +}) + +test_that("can round trip of ContentSql record/replay", { + expect_record_replay( + ContentSql("SELECT * FROM mtcars") + ) +}) + +test_that("can round trip of ContentSuggestions record/replay", { + expect_record_replay( + ContentSuggestions( + c( + "What is the total quantity sold for each product last quarter?", + "What is the average discount percentage for orders from the United States?", + "What is the average price of products in the 'electronics' category?" + ) + ) + ) +}) + +test_that("can round trip of ContentThinking record/replay", { + expect_record_replay( + ContentThinking("A **thought**.") + ) +}) + +test_that("can round trip of ContentTool record/replay", { + # TODO: barret - test tooldef, need to adjust replay to accept client to recontruct tooldef + expect_record_replay( + ContentToolRequest("ID", "tool_name", list(a = 1:2, b = "apple")) + ) + + with_chat(chat, { + }) +}) + +test_that("can round trip of ToolDef record/replay", { + with_chat(chat, { + expect_record_replay(tool_rnorm) + }) +}) + +test_that("can round trip of ContentToolResult record/replay", { + expect_record_replay( + ContentToolResult( + value = "VALUE", + error = NULL, + extra = list(extra = 1:2, b = "apple"), + request = NULL + ) + ) + # TODO: Barret test real error value + # TODO: Barret test with request object +}) + +test_that("can round trip of ContentUploaded record/replay", { + expect_record_replay(ContentUploaded("https://example.com/image.jpg")) +}) + +test_that("can round trip of ContentPDF record/replay", { + expect_record_replay(ContentPDF(type = "TYPE", data = "DATA")) +}) From 60e9a706eb6b741c1ae817496eddd7461342c9a3 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 15 May 2025 10:48:51 -0400 Subject: [PATCH 03/34] Drop chat state during serialization / unserialization. Instead, pass chat throughout --- R/content-replay.R | 96 ++++++++++++++++------------ man/contents_record.Rd | 6 +- tests/testthat/test-content-replay.R | 58 ++++++++++------- 3 files changed, 93 insertions(+), 67 deletions(-) diff --git a/R/content-replay.R b/R/content-replay.R index 794fde42..5469886a 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -16,7 +16,13 @@ NULL #' * `contents_record()` will accept a [Turn] or [Content] object and return a #' basic object that can be easily serialized. #' @export -contents_record <- new_generic("contents_record", "content") +contents_record <- new_generic( + "contents_record", + "content", + function(content, ..., chat) { + S7::S7_dispatch() + } +) # #' @export # contents_record_prop_names <- new_generic( # "contents_record_prop_names", @@ -43,7 +49,7 @@ contents_record <- new_generic("contents_record", "content") # ) # ) # } -method(contents_record, S7::S7_object) <- function(content) { +method(contents_record, S7::S7_object) <- function(content, ..., chat) { prop_names <- S7::prop_names(content) list( version = 1, @@ -52,7 +58,7 @@ method(contents_record, S7::S7_object) <- function(content) { lapply(prop_names, function(prop_name) { prop_value <- S7::prop(prop_name, object = content) if (S7_inherits(prop_value)) { - contents_record(prop_value) + contents_record(prop_value, chat = chat) } else { prop_value } @@ -61,13 +67,13 @@ method(contents_record, S7::S7_object) <- function(content) { ) ) } -method(contents_record, Turn) <- function(content) { +method(contents_record, Turn) <- function(content, ..., chat) { list( version = 1, class = class(content)[1], props = list( role = content@role, - contents = lapply(content@contents, contents_record), + contents = lapply(content@contents, contents_record, chat = chat), json = content@json, tokens = content@tokens, completed = content@completed @@ -77,7 +83,14 @@ method(contents_record, Turn) <- function(content) { #' @rdname contents_record #' @export -contents_replay <- function(obj) { +contents_replay <- function(obj, ..., chat) { + if (!(R6::is.R6(chat) && inherits(chat, "Chat"))) { + cli::cli_abort( + "Expected a Chat object at `chat=`, but received {.val {chat}}.", + call = caller_env() + ) + } + # Find any reason to not believe `obj` is a recorded object. # If not a recorded object, return it as is. # If it is a recorded s7 object, dispatch on the discovered class. @@ -138,7 +151,7 @@ contents_replay <- function(obj) { # An error will be thrown if a method is not found, # however we have a fallback for the `S7::S7_object` (the root base class) handler <- S7::method(contents_replay_s7, cls) - handler(cls, obj) + handler(cls, obj, chat = chat) } #' @rdname contents_record @@ -146,16 +159,16 @@ contents_replay <- function(obj) { contents_replay_s7 <- new_generic( "contents_replay_s7", "cls", - function(cls, obj) { + function(cls, obj, ..., chat) { S7::S7_dispatch() } ) -method(contents_replay_s7, S7::S7_object) <- function(cls, obj) { +method(contents_replay_s7, S7::S7_object) <- function(cls, obj, ..., chat) { stopifnot(obj$version == 1) - obj_props <- lapply(obj$props, contents_replay) + obj_props <- lapply(obj$props, contents_replay, chat = chat) ## While this should give prettier tracebacks, it doesn't work # > cls_name <- rlang::sym(obj$class[1]) # > rlang::inject((!!cls_name)(!!!obj_props)) @@ -167,37 +180,37 @@ method(contents_replay_s7, S7::S7_object) <- function(cls, obj) { rlang::inject(cls(!!!obj_props)) } -with_chat_env <- list2env(list()) -with_chat_set <- function(chat) { - if (is.null(chat)) { - with_chat_env$chat <- NULL - return() - } - if (!inherits(chat, "Chat")) { - cli::cli_abort( - "Expected a Chat object, but got {.val {chat}}.", - call = caller_env() - ) - } - with_chat_env$chat <- chat -} -with_chat_get <- function() { - chat <- with_chat_env$chat - if (is.null(chat)) { - cli::cli_abort( - "No Chat object found in the environment.", - call = caller_env() - ) - } - chat -} -with_chat <- function(chat, code) { - with_chat_set(chat) - on.exit(with_chat_set(NULL), add = TRUE) - force(code) -} +# with_chat_env <- list2env(list()) +# with_chat_set <- function(chat) { +# if (is.null(chat)) { +# with_chat_env$chat <- NULL +# return() +# } +# if (!inherits(chat, "Chat")) { +# cli::cli_abort( +# "Expected a Chat object, but got {.val {chat}}.", +# call = caller_env() +# ) +# } +# with_chat_env$chat <- chat +# } +# with_chat_get <- function() { +# chat <- with_chat_env$chat +# if (is.null(chat)) { +# cli::cli_abort( +# "No Chat object found in the environment.", +# call = caller_env() +# ) +# } +# chat +# } +# with_chat <- function(chat, code) { +# with_chat_set(chat) +# on.exit(with_chat_set(NULL), add = TRUE) +# force(code) +# } -method(contents_record, ToolDef) <- function(content) { +method(contents_record, ToolDef) <- function(content, ..., chat) { list( version = 1, class = class(content)[1], @@ -209,14 +222,13 @@ method(contents_record, ToolDef) <- function(content) { ) ) } -method(contents_replay_s7, ToolDef) <- function(cls, obj) { +method(contents_replay_s7, ToolDef) <- function(cls, obj, ..., chat) { if (obj$version != 1) { cli::cli_abort( "Unsupported version {.val {obj$version}}.", call = caller_env() ) } - chat <- with_chat_get() tools <- chat$get_tools() tool <- tools[[obj$props$name]] diff --git a/man/contents_record.Rd b/man/contents_record.Rd index d43e157e..b9015793 100644 --- a/man/contents_record.Rd +++ b/man/contents_record.Rd @@ -6,11 +6,11 @@ \alias{contents_replay_s7} \title{Save and restore content} \usage{ -contents_record(content, ...) +contents_record(content, ..., chat) -contents_replay(obj) +contents_replay(obj, ..., chat) -contents_replay_s7(cls, obj) +contents_replay_s7(cls, obj, ..., chat) } \description{ These generic functions can be use to convert \link{Turn} contents or \link{Content} diff --git a/tests/testthat/test-content-replay.R b/tests/testthat/test-content-replay.R index 8c357f97..06d922b3 100644 --- a/tests/testthat/test-content-replay.R +++ b/tests/testthat/test-content-replay.R @@ -1,18 +1,10 @@ -tool_rnorm <- tool( - stats::rnorm, - "Drawn numbers from a random normal distribution", - n = type_integer("The number of observations. Must be a positive integer."), - mean = type_number("The mean value of the distribution."), - sd = type_number( - "The standard deviation of the distribution. Must be a non-negative number." - ) -) - -chat <- chat_ollama(model = "llama3.2") -chat$register_tool(tool_rnorm) +expect_record_replay <- function( + x, + ..., + chat = chat_ollama_test("Be as terse as possible; no punctuation") +) { + rlang::check_dots_empty() - -expect_record_replay <- function(x) { # Simulate the full bookmarking experience: # * Record the object to something serializable # * Serialize the object to JSON via shiny; "bookmark" @@ -20,14 +12,15 @@ expect_record_replay <- function(x) { # * Replay the unserialized object to the original object # * Check that the replayed object has the same class as the original object # * Check that the replayed object has the same properties as the original object + # expect_silent({ - obj <- contents_record(x) + obj <- contents_record(x, chat = chat) # Bbookmark serialized <- shiny:::toJSON(obj) unserialized <- shiny:::safeFromJSON(serialized) - replayed <- contents_replay(unserialized) + replayed <- contents_replay(unserialized, chat = chat) # }) expect_s3_class(replayed, class(x)[1]) @@ -91,15 +84,36 @@ test_that("can round trip of ContentTool record/replay", { expect_record_replay( ContentToolRequest("ID", "tool_name", list(a = 1:2, b = "apple")) ) - - with_chat(chat, { - }) }) test_that("can round trip of ToolDef record/replay", { - with_chat(chat, { - expect_record_replay(tool_rnorm) - }) + chat <- chat_ollama_test("Be as terse as possible; no punctuation") + tool_rnorm <- tool( + stats::rnorm, + "Drawn numbers from a random normal distribution", + n = type_integer("The number of observations. Must be a positive integer."), + mean = type_number("The mean value of the distribution."), + sd = type_number( + "The standard deviation of the distribution. Must be a non-negative number." + ) + ) + chat$register_tool(tool_rnorm) + + # with_chat(chat, { + expect_record_replay(tool_rnorm, chat = chat) + # }) + + # with_chat(chat, { + expect_record_replay( + ContentToolRequest( + "ID", + "tool_name", + list(a = 1:2, b = "apple"), + tool = tool_rnorm + ), + chat = chat + ) + # }) }) test_that("can round trip of ContentToolResult record/replay", { From 17d41ebe18dda4911a67f582385f344ad847e7ea Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 15 May 2025 15:18:52 -0400 Subject: [PATCH 04/34] Relocate expect_record_replay --- R/content-replay.R | 36 ++++++++++++++++++++++++++++ tests/testthat/test-content-replay.R | 29 ---------------------- 2 files changed, 36 insertions(+), 29 deletions(-) diff --git a/R/content-replay.R b/R/content-replay.R index 5469886a..8dc9074c 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -396,3 +396,39 @@ tool_rnorm <- tool( # contents <- contents[contents != ""] # contents # } + +expect_record_replay <- function( + x, + ..., + chat = chat_ollama_test("Be as terse as possible; no punctuation") +) { + rlang::check_dots_empty() + + # Simulate the full bookmarking experience: + # * Record the object to something serializable + # * Serialize the object to JSON via shiny; "bookmark" + # * Unserialize the object from JSON via shiny; "restore" + # * Replay the unserialized object to the original object + # * Check that the replayed object has the same class as the original object + # * Check that the replayed object has the same properties as the original object + + obj <- contents_record(x, chat = chat) + + # obj_packed <- jsonlite:::pack(obj) + + # Work around Shiny's terrible JSON serialization + # Use `as.character()` to remove the JSON class so that it is double serialized :-/ + marshalled = as.character(jsonlite::serializeJSON(obj)) + + # Bookmark + serialized <- shiny:::toJSON(marshalled) + unserialized <- shiny:::safeFromJSON(serialized) + + # obj_unpacked <- jsonlite:::unpack(unserialized) + unmarshalled <- jsonlite::unserializeJSON(unserialized) + + replayed <- contents_replay(unmarshalled, chat = chat) + + expect_s3_class(replayed, class(x)[1]) + expect_equal(S7::props(replayed), S7::props(x)) +} diff --git a/tests/testthat/test-content-replay.R b/tests/testthat/test-content-replay.R index 06d922b3..1087a076 100644 --- a/tests/testthat/test-content-replay.R +++ b/tests/testthat/test-content-replay.R @@ -1,32 +1,3 @@ -expect_record_replay <- function( - x, - ..., - chat = chat_ollama_test("Be as terse as possible; no punctuation") -) { - rlang::check_dots_empty() - - # Simulate the full bookmarking experience: - # * Record the object to something serializable - # * Serialize the object to JSON via shiny; "bookmark" - # * Unserialize the object from JSON via shiny; "restore" - # * Replay the unserialized object to the original object - # * Check that the replayed object has the same class as the original object - # * Check that the replayed object has the same properties as the original object - - # expect_silent({ - obj <- contents_record(x, chat = chat) - - # Bbookmark - serialized <- shiny:::toJSON(obj) - unserialized <- shiny:::safeFromJSON(serialized) - - replayed <- contents_replay(unserialized, chat = chat) - # }) - - expect_s3_class(replayed, class(x)[1]) - expect_equal(S7::props(replayed), S7::props(x)) -} - # ------------------------------------------------------------------------- test_that("can round trip of Content record/replay", { From 8a3905d1a513c8dddd0922a2a80371fb2b5a5fef Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 15 May 2025 15:19:09 -0400 Subject: [PATCH 05/34] Test with registered tool --- tests/testthat/test-content-replay.R | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/testthat/test-content-replay.R b/tests/testthat/test-content-replay.R index 1087a076..eef34e8f 100644 --- a/tests/testthat/test-content-replay.R +++ b/tests/testthat/test-content-replay.R @@ -96,6 +96,33 @@ test_that("can round trip of ContentToolResult record/replay", { request = NULL ) ) + + chat <- chat_ollama_test("Be as terse as possible; no punctuation") + tool_rnorm <- tool( + stats::rnorm, + "Drawn numbers from a random normal distribution", + n = type_integer("The number of observations. Must be a positive integer."), + mean = type_number("The mean value of the distribution."), + sd = type_number( + "The standard deviation of the distribution. Must be a non-negative number." + ) + ) + chat$register_tool(tool_rnorm) + + expect_record_replay( + ContentToolResult( + value = "VALUE", + error = try(stop("boom"), silent = TRUE), + extra = list(extra = 1:2, b = "apple"), + request = ContentToolRequest( + "ID", + "tool_name", + list(a = 1:2, b = "apple"), + tool = tool_rnorm + ) + ), + chat = chat + ) # TODO: Barret test real error value # TODO: Barret test with request object }) From 540ff71970dda1eb4c63a0dd288ea676458c6b01 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 15 May 2025 15:20:10 -0400 Subject: [PATCH 06/34] Store more information in a ToolDef record. On restore, make a new ToolDef if one does not exist --- R/content-replay.R | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/R/content-replay.R b/R/content-replay.R index 8dc9074c..b68f0b25 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -76,7 +76,8 @@ method(contents_record, Turn) <- function(content, ..., chat) { contents = lapply(content@contents, contents_record, chat = chat), json = content@json, tokens = content@tokens, - completed = content@completed + completed = content@completed, + # text = getter only! ) ) } @@ -175,8 +176,6 @@ method(contents_replay_s7, S7::S7_object) <- function(cls, obj, ..., chat) { # Error in `ellmer::Turn`(role = "user", contents = list(), json = list(), : # could not find function "ellmer::Turn" - print(cls) - rlang::inject(cls(!!!obj_props)) } @@ -215,10 +214,15 @@ method(contents_record, ToolDef) <- function(content, ..., chat) { version = 1, class = class(content)[1], props = list( - name = content@name - # description = content$description, - # arguments = content$arguments, - # annotations = content$annotations + name = content@name, + # Do not record the function! + # It is not serializable and will not be neeeded after replay as the _real_ tool would be leveraged. + # However, keep all the other properties as the metadata could be useful. + fun = NULL, + description = content@description, + arguments = content@arguments, + convert = content@convert, + annotations = content@annotations ) ) } @@ -229,16 +233,29 @@ method(contents_replay_s7, ToolDef) <- function(cls, obj, ..., chat) { call = caller_env() ) } + tools <- chat$get_tools() + matched_tool <- tools[[obj$props$name]] - tool <- tools[[obj$props$name]] - # Return matched tool or NULL - return(tool) + if (!is.null(matched_tool)) { + matched_tool + } + + # If no tool is found, return placeholder tool + ToolDef( + name = obj$props$name, + # fun = NULL, # fun was not serialized + description = obj$props$description, + # TODO: Barret fix this + arguments = contents_replay(obj$props$arguments, chat = chat), + convert = obj$props$convert, + annotations = obj$props$annotations + ) } tool_rnorm <- tool( stats::rnorm, - "Drawn numbers from a random normal distribution", + .description = "Drawn numbers from a random normal distribution", n = type_integer("The number of observations. Must be a positive integer."), mean = type_number("The mean value of the distribution."), sd = type_number( From f423c076c70b9a3128e46cf2d9a492beda26377e Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 15 May 2025 15:20:20 -0400 Subject: [PATCH 07/34] document --- NAMESPACE | 2 +- man/chat_portkey.Rd | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 41dc2837..5cd0ed1f 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -64,13 +64,13 @@ export(interpolate_file) export(interpolate_package) export(live_browser) export(live_console) -export(models_portkey) export(models_anthropic) export(models_aws_bedrock) export(models_google_gemini) export(models_google_vertex) export(models_ollama) export(models_openai) +export(models_portkey) export(models_vllm) export(parallel_chat) export(parallel_chat_structured) diff --git a/man/chat_portkey.Rd b/man/chat_portkey.Rd index 159b72ab..1904fd72 100644 --- a/man/chat_portkey.Rd +++ b/man/chat_portkey.Rd @@ -32,9 +32,9 @@ models_portkey( \item{virtual_key}{A virtual identifier storing LLM provider's API key. See \href{https://portkey.ai/docs/product/ai-gateway/virtual-keys}{documentation}.} -\item{model}{The model to use for the chat. The default, \code{NULL}, will pick -a reasonable default, and tell you about. We strongly recommend explicitly -choosing a model for all but the most casual use.} +\item{model}{The model to use for the chat (defaults to "gpt-4o"). +We regularly update the default, so we strongly recommend explicitly specifying a model for anything other than casual use. +Use \code{models_openai()} to see all options.} \item{params}{Common model parameters, usually created by \code{\link[=params]{params()}}.} From 36544607008a24fbcbe4016409711ff37f712a32 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 19 May 2025 15:48:04 -0400 Subject: [PATCH 08/34] Remove commented code --- R/content-replay.R | 231 +-------------------------------------------- 1 file changed, 2 insertions(+), 229 deletions(-) diff --git a/R/content-replay.R b/R/content-replay.R index b68f0b25..4b5786e0 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -23,32 +23,6 @@ contents_record <- new_generic( S7::S7_dispatch() } ) -# #' @export -# contents_record_prop_names <- new_generic( -# "contents_record_prop_names", -# "content" -# ) - -# contents_replay_impl <- new_generic( -# "contents_replay_impl", -# "type", -# function(state, type) { -# S7::S7_dispatch() -# } -# ) - -# method(contents_record, Turn) <- function(content) { -# list( -# version = 1, -# type = "turn", -# props = list( -# role = content@role, -# contents = contents_record(content@contents), -# tokens = content@tokens, -# completed = content@completed -# ) -# ) -# } method(contents_record, S7::S7_object) <- function(content, ..., chat) { prop_names <- S7::prop_names(content) list( @@ -98,33 +72,19 @@ contents_replay <- function(obj, ..., chat) { if (!is.list(obj)) { return(obj) - # cli::cli_abort( - # "Expected a list, but got {.val {obj}}.", - # call = caller_env() - # ) } if (!all(c("version", "class", "props") %in% names(obj))) { return(obj) - # cli::cli_abort( - # "Expected a list with version, class, and props keys, but got {.val {obj}}.", - # call = caller_env() - # ) } + class_value <- obj$class if (!(is.character(class_value) && length(class_value) > 0)) { return(obj) - # cli::cli_abort( - # "class key must be a string. {.val {class_value}}.", - # call = caller_env() - # ) } + pkg_cls <- strsplit(class_value[1], "::")[[1]] if (length(pkg_cls) != 2) { return(obj) - # cli::cli_abort( - # "class key must be a string with a package name. {.val {class_value}}.", - # call = caller_env() - # ) } pkg_name <- pkg_cls[1] cls_name <- pkg_cls[2] @@ -133,18 +93,10 @@ contents_replay <- function(obj, ..., chat) { if (is.null(cls)) { return(obj) - # cli::cli_abort( - # "class key must be a valid class name. {.val {class_value}}.", - # call = caller_env() - # ) } if (!S7_inherits(cls)) { return(obj) - # cli::cli_abort( - # "class key must be a S7 class. {.val {class_value}}.", - # call = caller_env() - # ) } # Manually retrieve the handler for the class as we dispatch on the class itself, @@ -179,35 +131,6 @@ method(contents_replay_s7, S7::S7_object) <- function(cls, obj, ..., chat) { rlang::inject(cls(!!!obj_props)) } -# with_chat_env <- list2env(list()) -# with_chat_set <- function(chat) { -# if (is.null(chat)) { -# with_chat_env$chat <- NULL -# return() -# } -# if (!inherits(chat, "Chat")) { -# cli::cli_abort( -# "Expected a Chat object, but got {.val {chat}}.", -# call = caller_env() -# ) -# } -# with_chat_env$chat <- chat -# } -# with_chat_get <- function() { -# chat <- with_chat_env$chat -# if (is.null(chat)) { -# cli::cli_abort( -# "No Chat object found in the environment.", -# call = caller_env() -# ) -# } -# chat -# } -# with_chat <- function(chat, code) { -# with_chat_set(chat) -# on.exit(with_chat_set(NULL), add = TRUE) -# force(code) -# } method(contents_record, ToolDef) <- function(content, ..., chat) { list( @@ -263,156 +186,6 @@ tool_rnorm <- tool( ) ) -# method(contents_replay_impl, "S7") <- function(state) { -# if (!is.list(state)) { -# cli::cli_abort( -# "Expected a list, but got {.val {state}}.", -# call = caller_env() -# ) -# } - -# contents <- lapply(prop_names, function(prop_name) { -# prop_value <- S7::prop(prop_name, object = content) -# contents_replay(prop_value) -# }) -# contents <- unlist(contents, recursive = FALSE) -# contents <- contents[!sapply(contents, is.null)] -# contents <- contents[contents != ""] -# contents -# } - -# contents_list_replay <- function(state) { -# } - -# contents_record_old <- new_generic("contents_record_old", "content") -# method(contents_record_old_prop_names, S7_object) <- function(content) { -# S7::prop_names(content) -# } - -# method(contents_record_old, S7_object) <- function(content) { -# prop_names <- contents_record_old_prop_names(content) -# if (length(prop_names) == 0) { -# return(NULL) -# } -# props <- lapply(prop_names, function(prop_name) { -# prop_value <- S7::prop(prop_name, object = content) -# contents_record_old(prop_value) -# }) -# props <- setNames(props, prop_names) -# list( -# version = 1, -# type = "s7", -# class = class(content), -# props = props -# ) -# } - -# method(contents_record_old, S7::class_list) <- function(content) { -# lapply(content, contents_record_old) -# } -# method(contents_record_old, S7::class_any) <- function(content) { -# content -# } - -# contents_replay_s7_cls <- function(state) { -# if (is.null(state)) { -# return(NULL) -# } -# if (state$version != 1) { -# cli::cli_abort( -# "Unsupported version {.val {state$version}}.", -# call = caller_env() -# ) -# } - -# if (!identical(state$type, "s7")) { -# cli::cli_abort( -# "Unsupported type {.val {state$type}}.", -# call = caller_env() -# ) -# } - -# class_value <- state$class -# if (is.null(class_value)) { -# cli::cli_abort( -# "class key must be provided. {.val {class_value}}." -# ) -# } -# if (!is.character(class_value)) { -# cli::cli_abort( -# "class key must be a string. {.val {class_value}}." -# ) -# } -# if (length(class_value) == 0) { -# cli::cli_abort( -# "class key must be a single string. {.val {class_value}}." -# ) -# } -# if (nchar(class_value[0]) == 0) { -# cli::cli_abort( -# "class key must be a non-empty string. {.val {class_value}}." -# ) -# } - -# s7_cls_info <- strsplit(class_value[0], "::")[[1]] -# s7_cls_pkg <- s7_cls_info[1] -# s7_cls_name <- s7_cls_info[2] -# s7_cls <- rlang::pkg_env(s7_cls_pkg)[[s7_cls_name]] - -# contents_replay(s7_cls, state) -# # s7_cls -# } - -# method(contents_replay, S7::class_list) <- function(state) { -# lapply(state, contents_replay) -# } -# method(contents_replay, S7::class_any) <- function(cls, state) { -# state -# } - -# method(contents_replay, S7_object) <- function(state, type) { -# prop_names <- contents_record_prop_names(content) -# if (length(prop_names) == 0) { -# return(NULL) -# } - -# props <- lapply(prop_names, function(prop_name) { -# prop_value <- S7::prop(prop_name, object = content) -# contents_replay(prop_value) -# }) - -# rlang::inject(cls(!!!state$props)) -# } - -# method(contents_replay, Turn) <- function(object) { -# if (object$version != 1) { -# cli::cli_abort( -# "Unsupported version {.val {object$version}}.", -# call = caller_env() -# ) -# } -# if (object$type != "turn") { -# cli::cli_abort( -# "Unsupported type {.val {object$type}}.", -# call = caller_env() -# ) -# } -# contents <- lapply(content@contents, contents_replay) -# contents <- unlist(contents, recursive = FALSE) -# contents <- contents[!sapply(contents, is.null)] -# contents <- contents[contents != ""] -# contents -# } -# method(contents_replay, S7_object) <- function(content) { -# contents <- lapply(prop_names, function(prop_name) { -# prop_value <- S7::prop(prop_name, object = content) -# contents_replay(prop_value) -# }) -# contents <- unlist(contents, recursive = FALSE) -# contents <- contents[!sapply(contents, is.null)] -# contents <- contents[contents != ""] -# contents -# } expect_record_replay <- function( x, From 2c3673c88a440197d9cef76fa976117d7f0ceaef Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 19 May 2025 15:50:05 -0400 Subject: [PATCH 09/34] Add pre/post checks when recording --- R/content-replay.R | 47 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/R/content-replay.R b/R/content-replay.R index 4b5786e0..e46db79e 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -11,18 +11,43 @@ NULL #' These generic functions can be use to convert [Turn] contents or [Content] #' objects into easily serializable representations. #' -#' * `contents_replay()` will accept a basic object and return a corresponding -#' [Turn] or [Content] object. -#' * `contents_record()` will accept a [Turn] or [Content] object and return a -#' basic object that can be easily serialized. +#' * `contents_record()` will accept a [Turn] or [Content] related objects and return a +#' basic list that can be easily serialized. +#' * `contents_replay()` will accept a basic list (from `contents_record()`) and +#' return a corresponding [Turn] or [Content] related object. +#' * `contents_replay_class()` is a generic function that is dispatched from +#' within `contents_replay()`. `contents_replay()` will retrieve the +#' corresponding contructor class from within the basic list information and +#' use the class for dispatching. #' @export -contents_record <- new_generic( - "contents_record", - "content", - function(content, ..., chat) { - S7::S7_dispatch() - } -) +contents_record <- + #' @export + #' @rdname contents_record + contents_record <- new_generic( + "contents_record", + "content", + function(content, ..., chat) { + if (!(R6::is.R6(chat) && inherits(chat, "Chat"))) { + cli::cli_abort( + "Expected a Chat object at `chat=`, but received {.val {chat}}.", + call = caller_env() + ) + } + + recorded <- S7::S7_dispatch() + + for (name in c("version", "class", "props")) { + if (!name %in% names(recorded)) { + cli::cli_abort( + "Expected the recorded object to have a {.val {name}} property.", + call = caller_env() + ) + } + } + + recorded + } + ) method(contents_record, S7::S7_object) <- function(content, ..., chat) { prop_names <- S7::prop_names(content) list( From 2795f452cead0490a10b9f1bcad0800120b6450d Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 19 May 2025 15:51:16 -0400 Subject: [PATCH 10/34] Rename dispatch method --- NAMESPACE | 2 +- R/content-replay.R | 11 ++++++----- man/contents_record.Rd | 16 ++++++++++------ 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 5cd0ed1f..cfc849ee 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -54,7 +54,7 @@ export(contents_html) export(contents_markdown) export(contents_record) export(contents_replay) -export(contents_replay_s7) +export(contents_replay_class) export(contents_text) export(create_tool_def) export(google_upload) diff --git a/R/content-replay.R b/R/content-replay.R index e46db79e..f2151e7d 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -83,6 +83,7 @@ method(contents_record, Turn) <- function(content, ..., chat) { #' @rdname contents_record #' @export +# Holy "Holy Trait" dispatching, Batman! contents_replay <- function(obj, ..., chat) { if (!(R6::is.R6(chat) && inherits(chat, "Chat"))) { cli::cli_abort( @@ -128,14 +129,14 @@ contents_replay <- function(obj, ..., chat) { # not on an instance # An error will be thrown if a method is not found, # however we have a fallback for the `S7::S7_object` (the root base class) - handler <- S7::method(contents_replay_s7, cls) + handler <- S7::method(contents_replay_class, cls) handler(cls, obj, chat = chat) } #' @rdname contents_record #' @export -contents_replay_s7 <- new_generic( - "contents_replay_s7", +contents_replay_class <- new_generic( + "contents_replay_class", "cls", function(cls, obj, ..., chat) { S7::S7_dispatch() @@ -143,7 +144,7 @@ contents_replay_s7 <- new_generic( ) -method(contents_replay_s7, S7::S7_object) <- function(cls, obj, ..., chat) { +method(contents_replay_class, S7::S7_object) <- function(cls, obj, ..., chat) { stopifnot(obj$version == 1) obj_props <- lapply(obj$props, contents_replay, chat = chat) @@ -174,7 +175,7 @@ method(contents_record, ToolDef) <- function(content, ..., chat) { ) ) } -method(contents_replay_s7, ToolDef) <- function(cls, obj, ..., chat) { +method(contents_replay_class, ToolDef) <- function(cls, obj, ..., chat) { if (obj$version != 1) { cli::cli_abort( "Unsupported version {.val {obj$version}}.", diff --git a/man/contents_record.Rd b/man/contents_record.Rd index b9015793..639673eb 100644 --- a/man/contents_record.Rd +++ b/man/contents_record.Rd @@ -3,22 +3,26 @@ \name{contents_record} \alias{contents_record} \alias{contents_replay} -\alias{contents_replay_s7} +\alias{contents_replay_class} \title{Save and restore content} \usage{ contents_record(content, ..., chat) contents_replay(obj, ..., chat) -contents_replay_s7(cls, obj, ..., chat) +contents_replay_class(cls, obj, ..., chat) } \description{ These generic functions can be use to convert \link{Turn} contents or \link{Content} objects into easily serializable representations. \itemize{ -\item \code{contents_replay()} will accept a basic object and return a corresponding -\link{Turn} or \link{Content} object. -\item \code{contents_record()} will accept a \link{Turn} or \link{Content} object and return a -basic object that can be easily serialized. +\item \code{contents_record()} will accept a \link{Turn} or \link{Content} related objects and return a +basic list that can be easily serialized. +\item \code{contents_replay()} will accept a basic list (from \code{contents_record()}) and +return a corresponding \link{Turn} or \link{Content} related object. +\item \code{contents_replay_class()} is a generic function that is dispatched from +within \code{contents_replay()}. \code{contents_replay()} will retrieve the +corresponding contructor class from within the basic list information and +use the class for dispatching. } } From 8cc0574be4eddc0109132dc83a8d93523c312527 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 19 May 2025 15:51:51 -0400 Subject: [PATCH 11/34] Fixed bug in tooldef args --- R/content-replay.R | 50 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/R/content-replay.R b/R/content-replay.R index f2151e7d..bf60591d 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -169,7 +169,7 @@ method(contents_record, ToolDef) <- function(content, ..., chat) { # However, keep all the other properties as the metadata could be useful. fun = NULL, description = content@description, - arguments = content@arguments, + arguments = contents_record(content@arguments, chat = chat), convert = content@convert, annotations = content@annotations ) @@ -187,7 +187,7 @@ method(contents_replay_class, ToolDef) <- function(cls, obj, ..., chat) { matched_tool <- tools[[obj$props$name]] if (!is.null(matched_tool)) { - matched_tool + return(matched_tool) } # If no tool is found, return placeholder tool @@ -195,22 +195,48 @@ method(contents_replay_class, ToolDef) <- function(cls, obj, ..., chat) { name = obj$props$name, # fun = NULL, # fun was not serialized description = obj$props$description, - # TODO: Barret fix this arguments = contents_replay(obj$props$arguments, chat = chat), convert = obj$props$convert, annotations = obj$props$annotations ) } -tool_rnorm <- tool( - stats::rnorm, - .description = "Drawn numbers from a random normal distribution", - n = type_integer("The number of observations. Must be a positive integer."), - mean = type_number("The mean value of the distribution."), - sd = type_number( - "The standard deviation of the distribution. Must be a non-negative number." + +method(contents_record, TypeObject) <- function(content, ..., chat) { + list( + version = 1, + class = class(content)[1], + props = list( + description = content@description, + required = content@required, + properties = lapply( + content@properties, + contents_record, + chat = chat + ), + additional_properties = content@additional_properties + ) ) -) +} +method(contents_replay_class, TypeObject) <- function(cls, obj, ..., chat) { + if (obj$version != 1) { + cli::cli_abort( + "Unsupported version {.val {obj$version}}.", + call = caller_env() + ) + } + + TypeObject( + description = obj$props$description, + required = obj$props$required, + properties = lapply( + obj$props$properties, + contents_replay, + chat = chat + ), + additional_properties = obj$props$additional_properties + ) +} expect_record_replay <- function( @@ -247,4 +273,6 @@ expect_record_replay <- function( expect_s3_class(replayed, class(x)[1]) expect_equal(S7::props(replayed), S7::props(x)) + + invisible(replayed) } From 618290bcdc266697567bc37c09bb04ed33dd535e Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 19 May 2025 15:52:01 -0400 Subject: [PATCH 12/34] Don't record completed field --- R/content-replay.R | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/R/content-replay.R b/R/content-replay.R index bf60591d..67f5dae5 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -74,8 +74,7 @@ method(contents_record, Turn) <- function(content, ..., chat) { role = content@role, contents = lapply(content@contents, contents_record, chat = chat), json = content@json, - tokens = content@tokens, - completed = content@completed, + tokens = content@tokens # text = getter only! ) ) From 59e0b551a0ef8cc079f93223a03fc0b2496340a9 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 19 May 2025 16:00:24 -0400 Subject: [PATCH 13/34] Get the S7 class name in the traceback! --- R/content-replay.R | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/R/content-replay.R b/R/content-replay.R index 67f5dae5..2363e845 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -147,13 +147,29 @@ method(contents_replay_class, S7::S7_object) <- function(cls, obj, ..., chat) { stopifnot(obj$version == 1) obj_props <- lapply(obj$props, contents_replay, chat = chat) + ## While this should give prettier tracebacks, it doesn't work - # > cls_name <- rlang::sym(obj$class[1]) - # > rlang::inject((!!cls_name)(!!!obj_props)) + # > pkg_cls_name <- rlang::sym(obj$class[1]) + # > rlang::inject((!!pkg_cls_name)(!!!obj_props)) # Error in `ellmer::Turn`(role = "user", contents = list(), json = list(), : # could not find function "ellmer::Turn" - rlang::inject(cls(!!!obj_props)) + # Instead, use the package environment when calling the constructo + pkg_cls <- strsplit(obj$class[1], "::")[[1]] + if (length(pkg_cls) != 2) { + rlang::cli_abort( + "Invalid class name {.val {obj$class[1]}}. Explected a single `::` separator.", + call = caller_env() + ) + } + pkg_name <- pkg_cls[1] + cls_name <- pkg_cls[2] + withr::with_package( + pkg_name, + { + rlang::inject((!!rlang::sym(cls_name))(!!!obj_props)) + } + ) } From adf7bac2b56a666a6fac2622164af0799a1906e1 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 19 May 2025 16:00:46 -0400 Subject: [PATCH 14/34] Complete some TODOs --- tests/testthat/test-content-replay.R | 56 ++++++++++++++++++---------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/tests/testthat/test-content-replay.R b/tests/testthat/test-content-replay.R index eef34e8f..a0e71f07 100644 --- a/tests/testthat/test-content-replay.R +++ b/tests/testthat/test-content-replay.R @@ -51,9 +51,22 @@ test_that("can round trip of ContentThinking record/replay", { }) test_that("can round trip of ContentTool record/replay", { + chat <- chat_ollama_test("Be as terse as possible; no punctuation") + tool_rnorm <- tool( + stats::rnorm, + "Drawn numbers from a random normal distribution", + n = type_integer("The number of observations. Must be a positive integer."), + mean = type_number("The mean value of the distribution."), + sd = type_number( + "The standard deviation of the distribution. Must be a non-negative number." + ) + ) + chat$register_tool(tool_rnorm) + # TODO: barret - test tooldef, need to adjust replay to accept client to recontruct tooldef expect_record_replay( - ContentToolRequest("ID", "tool_name", list(a = 1:2, b = "apple")) + ContentToolRequest("ID", "tool_name", list(a = 1:2, b = "apple")), + chat = chat ) }) @@ -70,11 +83,8 @@ test_that("can round trip of ToolDef record/replay", { ) chat$register_tool(tool_rnorm) - # with_chat(chat, { expect_record_replay(tool_rnorm, chat = chat) - # }) - # with_chat(chat, { expect_record_replay( ContentToolRequest( "ID", @@ -84,7 +94,6 @@ test_that("can round trip of ToolDef record/replay", { ), chat = chat ) - # }) }) test_that("can round trip of ContentToolResult record/replay", { @@ -109,22 +118,31 @@ test_that("can round trip of ContentToolResult record/replay", { ) chat$register_tool(tool_rnorm) - expect_record_replay( - ContentToolResult( - value = "VALUE", - error = try(stop("boom"), silent = TRUE), - extra = list(extra = 1:2, b = "apple"), - request = ContentToolRequest( - "ID", - "tool_name", - list(a = 1:2, b = "apple"), - tool = tool_rnorm + replayed <- + expect_record_replay( + ContentToolResult( + value = "VALUE", + error = try(stop("boom"), silent = TRUE), + extra = list(extra = 1:2, b = "apple"), + request = ContentToolRequest( + "ID", + "tool_name", + list(a = 1:2, b = "apple"), + tool = tool_rnorm + ) + ), + chat = chat + ) + + tryCatch( + message(replayed@error), # re-throw error + error = function(e) { + expect_equal( + e$message, + "boom" ) - ), - chat = chat + } ) - # TODO: Barret test real error value - # TODO: Barret test with request object }) test_that("can round trip of ContentUploaded record/replay", { From e084919a7af5c9d4cf6c43df0266079fd635c904 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 20 May 2025 10:01:54 -0400 Subject: [PATCH 15/34] Update content-replay.R --- R/content-replay.R | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/R/content-replay.R b/R/content-replay.R index 2363e845..933637ed 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -20,10 +20,9 @@ NULL #' corresponding contructor class from within the basic list information and #' use the class for dispatching. #' @export +#' @rdname contents_record contents_record <- - #' @export - #' @rdname contents_record - contents_record <- new_generic( + new_generic( "contents_record", "content", function(content, ..., chat) { From 66b9649afe27917099a763a9c567ca3e67475217 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 20 May 2025 12:39:48 -0400 Subject: [PATCH 16/34] Allow for objects to be non-pkg S7 classes --- R/content-replay.R | 181 ++++++++++++++++++++++++++++++++--------- man/contents_record.Rd | 47 ++++++++++- 2 files changed, 189 insertions(+), 39 deletions(-) diff --git a/R/content-replay.R b/R/content-replay.R index 933637ed..a37549c4 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -19,6 +19,43 @@ NULL #' within `contents_replay()`. `contents_replay()` will retrieve the #' corresponding contructor class from within the basic list information and #' use the class for dispatching. +#' +#' Note, all S7 classes should have the same class name as the variable name. Ex: `FooBar <- new_class("FooBar")`, not `OtherName <- new_class("FooBar")`. This is a requirement for when replaying the object. +#' +#' @param content A [Turn] or [Content] object to be recorded. +#' @param obj A basic list (from `contents_record()`) to be replayed. +#' @param cls The class constructor to be used for replaying the object. +#' @param chat A [Chat] object to be used for recording and replaying. +#' @param env The environment to find non-package classes. +#' @param ... Not used. +#' +#' @examples +#' \dontrun{ +#' chat <- chat_ollama(model = "llama3.2") +#' turn <- Turn("user") +#' turn +#' #> +#' +#' # Get the turn record +#' # Note: Removes all S7 class instances +#' turn_recorded <- contents_record(turn, chat = chat) +#' str(turn_recorded) +#' #> List of 3 +#' #> $ version: num 1 +#' #> $ class : chr "ellmer::Turn" +#' #> $ props :List of 4 +#' #> ..$ role : chr "user" +#' #> ..$ contents: list() +#' #> ..$ json : list() +#' #> ..$ tokens : num [1:2] 0 0 +#' +#' # Restore the turn from the record +#' # Note: This will not restore the _original_ object, +#' # but a new object with the same properties +#' turn_replayed <- contents_replay(turn_recorded, chat = chat) +#' turn_replayed +#' #> +#' } #' @export #' @rdname contents_record contents_record <- @@ -43,12 +80,22 @@ contents_record <- ) } } + if ( + !is.character(recorded$class) || + length(recorded$class) != 1 + ) { + cli::cli_abort( + "Expected the recorded object to have a single $class name, containing `::` if the class is from a package.", + call = caller_env() + ) + } recorded } ) method(contents_record, S7::S7_object) <- function(content, ..., chat) { prop_names <- S7::prop_names(content) + class_name <- class(content)[1] list( version = 1, class = class(content)[1], @@ -82,7 +129,13 @@ method(contents_record, Turn) <- function(content, ..., chat) { #' @rdname contents_record #' @export # Holy "Holy Trait" dispatching, Batman! -contents_replay <- function(obj, ..., chat) { +contents_replay <- function( + obj, + ..., + cls = NULL, + chat, + env = rlang::caller_env() +) { if (!(R6::is.R6(chat) && inherits(chat, "Chat"))) { cli::cli_abort( "Expected a Chat object at `chat=`, but received {.val {chat}}.", @@ -106,14 +159,7 @@ contents_replay <- function(obj, ..., chat) { return(obj) } - pkg_cls <- strsplit(class_value[1], "::")[[1]] - if (length(pkg_cls) != 2) { - return(obj) - } - pkg_name <- pkg_cls[1] - cls_name <- pkg_cls[2] - - cls <- rlang::pkg_env(pkg_name)[[cls_name]] + cls <- get_cls_constructor(class_value[1], env = env) if (is.null(cls)) { return(obj) @@ -128,7 +174,7 @@ contents_replay <- function(obj, ..., chat) { # An error will be thrown if a method is not found, # however we have a fallback for the `S7::S7_object` (the root base class) handler <- S7::method(contents_replay_class, cls) - handler(cls, obj, chat = chat) + handler(cls, obj, chat = chat, env = env) } #' @rdname contents_record @@ -136,38 +182,46 @@ contents_replay <- function(obj, ..., chat) { contents_replay_class <- new_generic( "contents_replay_class", "cls", - function(cls, obj, ..., chat) { + function(cls, obj, ..., chat, env = rlang::caller_env()) { S7::S7_dispatch() } ) -method(contents_replay_class, S7::S7_object) <- function(cls, obj, ..., chat) { +method(contents_replay_class, S7::S7_object) <- function( + cls, + obj, + ..., + chat, + env = rlang::caller_env() +) { stopifnot(obj$version == 1) obj_props <- lapply(obj$props, contents_replay, chat = chat) - ## While this should give prettier tracebacks, it doesn't work - # > pkg_cls_name <- rlang::sym(obj$class[1]) - # > rlang::inject((!!pkg_cls_name)(!!!obj_props)) - # Error in `ellmer::Turn`(role = "user", contents = list(), json = list(), : - # could not find function "ellmer::Turn" - - # Instead, use the package environment when calling the constructo - pkg_cls <- strsplit(obj$class[1], "::")[[1]] - if (length(pkg_cls) != 2) { - rlang::cli_abort( - "Invalid class name {.val {obj$class[1]}}. Explected a single `::` separator.", - call = caller_env() + class_value <- obj$class[1] + if (grepl("::", class_value, fixed = TRUE)) { + # If the class is a package class, use the package name to find the constructor + # This allows use to reach into private namespaces within the package + pkg_cls <- strsplit(class_value, "::")[[1]] + pkg_name <- pkg_cls[1] + + rlang::check_installed( + pkg_name, + reason = "for `contents_replay()` to restore the chat content." ) + cls_name <- pkg_cls[2] + env <- rlang::pkg_env(pkg_name) + } else { + cls_name <- class_value } - pkg_name <- pkg_cls[1] - cls_name <- pkg_cls[2] - withr::with_package( - pkg_name, - { - rlang::inject((!!rlang::sym(cls_name))(!!!obj_props)) - } + + # While this seems like a bit of extra work, the tracebacks are accurate + # vs referencing an unrelated parameter name in the traceback + rlang::exec( + cls_name, + !!!obj_props, + .env = env ) } @@ -189,7 +243,13 @@ method(contents_record, ToolDef) <- function(content, ..., chat) { ) ) } -method(contents_replay_class, ToolDef) <- function(cls, obj, ..., chat) { +method(contents_replay_class, ToolDef) <- function( + cls, + obj, + ..., + chat, + env = rlang::caller_env() +) { if (obj$version != 1) { cli::cli_abort( "Unsupported version {.val {obj$version}}.", @@ -232,7 +292,13 @@ method(contents_record, TypeObject) <- function(content, ..., chat) { ) ) } -method(contents_replay_class, TypeObject) <- function(cls, obj, ..., chat) { +method(contents_replay_class, TypeObject) <- function( + cls, + obj, + ..., + chat, + env = rlang::caller_env() +) { if (obj$version != 1) { cli::cli_abort( "Unsupported version {.val {obj$version}}.", @@ -253,10 +319,49 @@ method(contents_replay_class, TypeObject) <- function(cls, obj, ..., chat) { } +#' Retrieve the class constructor +#' +#' @description +#' The class for S7 Classes are stored as "package::class" in the recorded object. +#' However, it does not mean that the class constructor is exported in the namespace. +#' Therefore, this function will reach into the package environment to retrieve the constructor. +#' If the class is not a _package_ class, it will return the object as is given the `env`. +#' +#' @param class_value The single string representing the `package::class` to retrieve the constructor. +#' @param ... Not used. +#' @param env The environment to find non-package class constructors. +#' @return The constructor function for the class. +#' @noRd +get_cls_constructor <- function(class_value, ..., env = rlang::caller_env()) { + rlang::check_dots_empty() + + pkg_cls <- strsplit(class_value, "::")[[1]] + if (length(pkg_cls) == 1) { + # If the class is not a package class, return the object as is + # This is the case for local S7 objects + rlang::eval_bare(rlang::sym(pkg_cls), env = env) + } else if (length(pkg_cls) == 2) { + pkg_name <- pkg_cls[1] + cls_name <- pkg_cls[2] + + rlang::check_installed( + pkg_name, + reason = "for `contents_replay()` to restore the chat content." + ) + rlang::pkg_env(pkg_name)[[cls_name]] + } else { + cli::cli_abort( + "Invalid class name {.val {class_value[1]}}. Expected a single (or missing) `::` separator, not multiple.", + call = caller_env() + ) + } +} + expect_record_replay <- function( x, ..., - chat = chat_ollama_test("Be as terse as possible; no punctuation") + chat = chat_ollama_test("Be as terse as possible; no punctuation"), + env = rlang::caller_env() ) { rlang::check_dots_empty() @@ -274,16 +379,18 @@ expect_record_replay <- function( # Work around Shiny's terrible JSON serialization # Use `as.character()` to remove the JSON class so that it is double serialized :-/ - marshalled = as.character(jsonlite::serializeJSON(obj)) + marshalled <- list( + "my_chat" = as.character(jsonlite::serializeJSON(obj)) + ) # Bookmark serialized <- shiny:::toJSON(marshalled) unserialized <- shiny:::safeFromJSON(serialized) # obj_unpacked <- jsonlite:::unpack(unserialized) - unmarshalled <- jsonlite::unserializeJSON(unserialized) + unmarshalled <- jsonlite::unserializeJSON(unserialized$my_chat) - replayed <- contents_replay(unmarshalled, chat = chat) + replayed <- contents_replay(unmarshalled, chat = chat, env = env) expect_s3_class(replayed, class(x)[1]) expect_equal(S7::props(replayed), S7::props(x)) diff --git a/man/contents_record.Rd b/man/contents_record.Rd index 639673eb..b0c243fe 100644 --- a/man/contents_record.Rd +++ b/man/contents_record.Rd @@ -8,9 +8,22 @@ \usage{ contents_record(content, ..., chat) -contents_replay(obj, ..., chat) +contents_replay(obj, ..., cls = NULL, chat, env = rlang::caller_env()) -contents_replay_class(cls, obj, ..., chat) +contents_replay_class(cls, obj, ..., chat, env = rlang::caller_env()) +} +\arguments{ +\item{content}{A \link{Turn} or \link{Content} object to be recorded.} + +\item{...}{Not used.} + +\item{chat}{A \link{Chat} object to be used for recording and replaying.} + +\item{obj}{A basic list (from \code{contents_record()}) to be replayed.} + +\item{cls}{The class constructor to be used for replaying the object.} + +\item{env}{The environment to find non-package classes.} } \description{ These generic functions can be use to convert \link{Turn} contents or \link{Content} @@ -25,4 +38,34 @@ within \code{contents_replay()}. \code{contents_replay()} will retrieve the corresponding contructor class from within the basic list information and use the class for dispatching. } + +Note, all S7 classes should have the same class name as the variable name. Ex: \code{FooBar <- new_class("FooBar")}, not \code{OtherName <- new_class("FooBar")}. This is a requirement for when replaying the object. +} +\examples{ +\dontrun{ +chat <- chat_ollama(model = "llama3.2") +turn <- Turn("user") +turn +#> + +# Get the turn record +# Note: Removes all S7 class instances +turn_recorded <- contents_record(turn, chat = chat) +str(turn_recorded) +#> List of 3 +#> $ version: num 1 +#> $ class : chr "ellmer::Turn" +#> $ props :List of 4 +#> ..$ role : chr "user" +#> ..$ contents: list() +#> ..$ json : list() +#> ..$ tokens : num [1:2] 0 0 + +# Restore the turn from the record +# Note: This will not restore the _original_ object, +# but a new object with the same properties +turn_replayed <- contents_replay(turn_recorded, chat = chat) +turn_replayed +#> +} } From 0dd25a4e53af1daee33787ce6369434c9f142a53 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 20 May 2025 12:40:04 -0400 Subject: [PATCH 17/34] Error when S7 classes can't be found --- R/content-replay.R | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/R/content-replay.R b/R/content-replay.R index a37549c4..7ea165eb 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -162,11 +162,17 @@ contents_replay <- function( cls <- get_cls_constructor(class_value[1], env = env) if (is.null(cls)) { - return(obj) + cli::cli_abort( + "Unable to find the S7 class: {.val {class_value[1]}}.", + call = caller_env() + ) } if (!S7_inherits(cls)) { - return(obj) + cli::cli_abort( + "The object returned for {.val {class_value[1]}} is not an S7 class.", + call = caller_env() + ) } # Manually retrieve the handler for the class as we dispatch on the class itself, From a7b24cb07c2a868a98e9fc968a649e1419a598ff Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 20 May 2025 12:40:15 -0400 Subject: [PATCH 18/34] Update test-content-replay.R --- tests/testthat/test-content-replay.R | 53 ++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/tests/testthat/test-content-replay.R b/tests/testthat/test-content-replay.R index a0e71f07..d7b11a5f 100644 --- a/tests/testthat/test-content-replay.R +++ b/tests/testthat/test-content-replay.R @@ -63,7 +63,6 @@ test_that("can round trip of ContentTool record/replay", { ) chat$register_tool(tool_rnorm) - # TODO: barret - test tooldef, need to adjust replay to accept client to recontruct tooldef expect_record_replay( ContentToolRequest("ID", "tool_name", list(a = 1:2, b = "apple")), chat = chat @@ -135,7 +134,7 @@ test_that("can round trip of ContentToolResult record/replay", { ) tryCatch( - message(replayed@error), # re-throw error + signalCondition(replayed@error), # re-throw error error = function(e) { expect_equal( e$message, @@ -152,3 +151,53 @@ test_that("can round trip of ContentUploaded record/replay", { test_that("can round trip of ContentPDF record/replay", { expect_record_replay(ContentPDF(type = "TYPE", data = "DATA")) }) + +test_that("non-package classes are recorded/replayed by default", { + chat <- chat_ollama_test("Be as terse as possible; no punctuation") + + LocalClass <- S7::new_class( + "LocalClass", + properties = list( + name = prop_string() + ), + # Make sure to unset the package being used! + # Within testing, it sets the package to "ellmer" + package = NULL + ) + + expect_record_replay(LocalClass("testname"), chat = chat) +}) + + +test_that("unknown classes cause errors", { + chat <- chat_ollama_test("Be as terse as possible; no punctuation") + recorded <- contents_record(Turn("user"), chat = chat) + recorded$class <- "ellmer::Turn2" + + expect_error( + contents_replay(recorded, chat = chat), + "Unable to find the S7 class" + ) +}) + +test_that("replay classes are S7 classes", { + OtherName <- S7::new_class( + "LocalClass", + properties = list( + name = prop_string() + ), + # Make sure to unset the package being used! + # Within testing, it sets the package to "ellmer" + package = NULL + ) + LocalClass <- function(name) { + OtherName(name = name) + } + + chat <- chat_ollama_test("Be as terse as possible; no punctuation") + recorded <- contents_record(LocalClass("testname"), chat = chat) + expect_error( + contents_replay(recorded, chat = chat), + "is not an S7 class" + ) +}) From f268b963253e295890f5bc3439d8f9d604c0f1a5 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 20 May 2025 12:54:59 -0400 Subject: [PATCH 19/34] Move test helper to helper file. Rename it --- R/content-replay.R | 45 ++------------------------ tests/testthat/helper-content-replay.R | 43 ++++++++++++++++++++++++ tests/testthat/test-content-replay.R | 32 +++++++++--------- 3 files changed, 61 insertions(+), 59 deletions(-) create mode 100644 tests/testthat/helper-content-replay.R diff --git a/R/content-replay.R b/R/content-replay.R index 7ea165eb..ac6eb836 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -217,7 +217,7 @@ method(contents_replay_class, S7::S7_object) <- function( reason = "for `contents_replay()` to restore the chat content." ) cls_name <- pkg_cls[2] - env <- rlang::pkg_env(pkg_name) + env <- rlang::ns_env(pkg_name) } else { cls_name <- class_value } @@ -354,7 +354,7 @@ get_cls_constructor <- function(class_value, ..., env = rlang::caller_env()) { pkg_name, reason = "for `contents_replay()` to restore the chat content." ) - rlang::pkg_env(pkg_name)[[cls_name]] + rlang::ns_env(pkg_name)[[cls_name]] } else { cli::cli_abort( "Invalid class name {.val {class_value[1]}}. Expected a single (or missing) `::` separator, not multiple.", @@ -362,44 +362,3 @@ get_cls_constructor <- function(class_value, ..., env = rlang::caller_env()) { ) } } - -expect_record_replay <- function( - x, - ..., - chat = chat_ollama_test("Be as terse as possible; no punctuation"), - env = rlang::caller_env() -) { - rlang::check_dots_empty() - - # Simulate the full bookmarking experience: - # * Record the object to something serializable - # * Serialize the object to JSON via shiny; "bookmark" - # * Unserialize the object from JSON via shiny; "restore" - # * Replay the unserialized object to the original object - # * Check that the replayed object has the same class as the original object - # * Check that the replayed object has the same properties as the original object - - obj <- contents_record(x, chat = chat) - - # obj_packed <- jsonlite:::pack(obj) - - # Work around Shiny's terrible JSON serialization - # Use `as.character()` to remove the JSON class so that it is double serialized :-/ - marshalled <- list( - "my_chat" = as.character(jsonlite::serializeJSON(obj)) - ) - - # Bookmark - serialized <- shiny:::toJSON(marshalled) - unserialized <- shiny:::safeFromJSON(serialized) - - # obj_unpacked <- jsonlite:::unpack(unserialized) - unmarshalled <- jsonlite::unserializeJSON(unserialized$my_chat) - - replayed <- contents_replay(unmarshalled, chat = chat, env = env) - - expect_s3_class(replayed, class(x)[1]) - expect_equal(S7::props(replayed), S7::props(x)) - - invisible(replayed) -} diff --git a/tests/testthat/helper-content-replay.R b/tests/testthat/helper-content-replay.R new file mode 100644 index 00000000..feaa1040 --- /dev/null +++ b/tests/testthat/helper-content-replay.R @@ -0,0 +1,43 @@ +test_record_replay <- function( + x, + ..., + chat = chat_ollama_test("Be as terse as possible; no punctuation"), + env = rlang::caller_env() +) { + rlang::check_dots_empty() + + shiny__to_json <- rlang::ns_env("shiny")[["toJSON"]] + shiny__safe_from_json <- rlang::ns_env("shiny")[["safeFromJSON"]] + + # Simulate the full bookmarking experience: + # * Record the object to something serializable + # * Serialize the object to JSON via shiny; "bookmark" + # * Unserialize the object from JSON via shiny; "restore" + # * Replay the unserialized object to the original object + # * Check that the replayed object has the same class as the original object + # * Check that the replayed object has the same properties as the original object + + obj <- contents_record(x, chat = chat) + + # obj_packed <- jsonlite:::pack(obj) + + # Work around Shiny's terrible JSON serialization + # Use `as.character()` to remove the JSON class so that it is double serialized :-/ + marshalled <- list( + "my_chat" = as.character(jsonlite::serializeJSON(obj)) + ) + + # Bookmark + serialized <- shiny__to_json(marshalled) + unserialized <- shiny__safe_from_json(serialized) + + # obj_unpacked <- jsonlite:::unpack(unserialized) + unmarshalled <- jsonlite::unserializeJSON(unserialized$my_chat) + + replayed <- contents_replay(unmarshalled, chat = chat, env = env) + + expect_s3_class(replayed, class(x)[1]) + expect_equal(S7::props(replayed), S7::props(x)) + + invisible(replayed) +} diff --git a/tests/testthat/test-content-replay.R b/tests/testthat/test-content-replay.R index d7b11a5f..a19496ae 100644 --- a/tests/testthat/test-content-replay.R +++ b/tests/testthat/test-content-replay.R @@ -1,39 +1,39 @@ # ------------------------------------------------------------------------- test_that("can round trip of Content record/replay", { - expect_record_replay(Content()) + test_record_replay(Content()) }) test_that("can round trip of ContentText record/replay", { - expect_record_replay(ContentText("hello world")) + test_record_replay(ContentText("hello world")) }) test_that("can round trip of ContentImageInline record/replay", { - expect_record_replay( + test_record_replay( ContentImageInline("image/png", "abcd123") ) }) test_that("can round trip of ContentImageRemote record/replay", { - expect_record_replay( + test_record_replay( ContentImageRemote("https://example.com/image.jpg", detail = "") ) }) test_that("can round trip of ContentJson record/replay", { - expect_record_replay( + test_record_replay( ContentJson(list(a = 1:2, b = "apple")) ) }) test_that("can round trip of ContentSql record/replay", { - expect_record_replay( + test_record_replay( ContentSql("SELECT * FROM mtcars") ) }) test_that("can round trip of ContentSuggestions record/replay", { - expect_record_replay( + test_record_replay( ContentSuggestions( c( "What is the total quantity sold for each product last quarter?", @@ -45,7 +45,7 @@ test_that("can round trip of ContentSuggestions record/replay", { }) test_that("can round trip of ContentThinking record/replay", { - expect_record_replay( + test_record_replay( ContentThinking("A **thought**.") ) }) @@ -63,7 +63,7 @@ test_that("can round trip of ContentTool record/replay", { ) chat$register_tool(tool_rnorm) - expect_record_replay( + test_record_replay( ContentToolRequest("ID", "tool_name", list(a = 1:2, b = "apple")), chat = chat ) @@ -82,9 +82,9 @@ test_that("can round trip of ToolDef record/replay", { ) chat$register_tool(tool_rnorm) - expect_record_replay(tool_rnorm, chat = chat) + test_record_replay(tool_rnorm, chat = chat) - expect_record_replay( + test_record_replay( ContentToolRequest( "ID", "tool_name", @@ -96,7 +96,7 @@ test_that("can round trip of ToolDef record/replay", { }) test_that("can round trip of ContentToolResult record/replay", { - expect_record_replay( + test_record_replay( ContentToolResult( value = "VALUE", error = NULL, @@ -118,7 +118,7 @@ test_that("can round trip of ContentToolResult record/replay", { chat$register_tool(tool_rnorm) replayed <- - expect_record_replay( + test_record_replay( ContentToolResult( value = "VALUE", error = try(stop("boom"), silent = TRUE), @@ -145,11 +145,11 @@ test_that("can round trip of ContentToolResult record/replay", { }) test_that("can round trip of ContentUploaded record/replay", { - expect_record_replay(ContentUploaded("https://example.com/image.jpg")) + test_record_replay(ContentUploaded("https://example.com/image.jpg")) }) test_that("can round trip of ContentPDF record/replay", { - expect_record_replay(ContentPDF(type = "TYPE", data = "DATA")) + test_record_replay(ContentPDF(type = "TYPE", data = "DATA")) }) test_that("non-package classes are recorded/replayed by default", { @@ -165,7 +165,7 @@ test_that("non-package classes are recorded/replayed by default", { package = NULL ) - expect_record_replay(LocalClass("testname"), chat = chat) + test_record_replay(LocalClass("testname"), chat = chat) }) From e9cba4093f90c1a453bc6d17c75a87501d2dab86 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 20 May 2025 13:17:06 -0400 Subject: [PATCH 20/34] Add Barret as author --- DESCRIPTION | 2 ++ man/ellmer-package.Rd | 2 ++ 2 files changed, 4 insertions(+) diff --git a/DESCRIPTION b/DESCRIPTION index 9052dfc5..0c658a98 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -8,6 +8,8 @@ Authors@R: c( person("Aaron", "Jacobs", role = "aut"), person("Garrick", "Aden-Buie", , "garrick@posit.co", role = "aut", comment = c(ORCID = "0000-0002-7111-0077")), + person("Barret", "Schloerke", , "barret@posit.co", role = "aut", + comment = c(ORCID = "0000-0001-9986-114X")), person("Posit Software, PBC", role = c("cph", "fnd"), comment = c(ROR = "03wc8by49")) ) diff --git a/man/ellmer-package.Rd b/man/ellmer-package.Rd index 606bac89..f609548d 100644 --- a/man/ellmer-package.Rd +++ b/man/ellmer-package.Rd @@ -26,6 +26,8 @@ Authors: \itemize{ \item Joe Cheng \item Aaron Jacobs + \item Garrick Aden-Buie \email{garrick@posit.co} (\href{https://orcid.org/0000-0002-7111-0077}{ORCID}) + \item Barret Schloerke \email{barret@posit.co} (\href{https://orcid.org/0000-0001-9986-114X}{ORCID}) } Other contributors: From f51229eccb5aa992fe5a6fde5a9a139385f27d7d Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 20 May 2025 13:17:14 -0400 Subject: [PATCH 21/34] Update _pkgdown.yml --- _pkgdown.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/_pkgdown.yml b/_pkgdown.yml index ad52a902..94a63ed4 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -59,6 +59,7 @@ reference: - title: Utilities contents: - contents_text + - contents_record - params - title: Deprecated functions From ae4dc7ebd6ec08306ea10fef6f6c4fab276ab227 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 20 May 2025 14:14:31 -0400 Subject: [PATCH 22/34] Add news entry --- NEWS.md | 6 ++++++ R/content-replay.R | 6 +++--- man/contents_record.Rd | 6 +++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/NEWS.md b/NEWS.md index ba5a9a33..9f881c70 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,11 @@ # ellmer (development version) +## New features + +## Developer tooling + +* Added `contents_record()`, `contents_replay()`, and `contents_replay_class()` to record and replay `Turn` related information from a `Chat` instance (#502). For example, these methods can be used for bookmarking within `{shinychat}`. + # ellmer 0.2.0 ## Breaking changes diff --git a/R/content-replay.R b/R/content-replay.R index ac6eb836..10e61cd6 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -22,10 +22,10 @@ NULL #' #' Note, all S7 classes should have the same class name as the variable name. Ex: `FooBar <- new_class("FooBar")`, not `OtherName <- new_class("FooBar")`. This is a requirement for when replaying the object. #' -#' @param content A [Turn] or [Content] object to be recorded. +#' @param content A [Turn] or [Content] object to have its record retrieved. #' @param obj A basic list (from `contents_record()`) to be replayed. #' @param cls The class constructor to be used for replaying the object. -#' @param chat A [Chat] object to be used for recording and replaying. +#' @param chat A [Chat] object to be used for context. #' @param env The environment to find non-package classes. #' @param ... Not used. #' @@ -36,7 +36,7 @@ NULL #' turn #' #> #' -#' # Get the turn record +#' # Get the turn's record #' # Note: Removes all S7 class instances #' turn_recorded <- contents_record(turn, chat = chat) #' str(turn_recorded) diff --git a/man/contents_record.Rd b/man/contents_record.Rd index b0c243fe..99d4deb5 100644 --- a/man/contents_record.Rd +++ b/man/contents_record.Rd @@ -13,11 +13,11 @@ contents_replay(obj, ..., cls = NULL, chat, env = rlang::caller_env()) contents_replay_class(cls, obj, ..., chat, env = rlang::caller_env()) } \arguments{ -\item{content}{A \link{Turn} or \link{Content} object to be recorded.} +\item{content}{A \link{Turn} or \link{Content} object to have its record retrieved.} \item{...}{Not used.} -\item{chat}{A \link{Chat} object to be used for recording and replaying.} +\item{chat}{A \link{Chat} object to be used for context.} \item{obj}{A basic list (from \code{contents_record()}) to be replayed.} @@ -48,7 +48,7 @@ turn <- Turn("user") turn #> -# Get the turn record +# Get the turn's record # Note: Removes all S7 class instances turn_recorded <- contents_record(turn, chat = chat) str(turn_recorded) From 222738290807d350b9c8b8d63e7bd14d1da98aaa Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 20 May 2025 14:17:34 -0400 Subject: [PATCH 23/34] bump version --- DESCRIPTION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index 0c658a98..5d607fac 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: ellmer Title: Chat with Large Language Models -Version: 0.2.0.9000 +Version: 0.2.0.9001 Authors@R: c( person("Hadley", "Wickham", , "hadley@posit.co", role = c("aut", "cre"), comment = c(ORCID = "0000-0003-4757-117X")), From a011e7bb24bf39bd59041313be41c832e6aa6860 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 21 May 2025 12:19:18 -0400 Subject: [PATCH 24/34] Loop over list elements when recording or replaying --- R/content-replay.R | 276 ++++++++++++++++++--------- man/contents_record.Rd | 6 +- tests/testthat/test-content-replay.R | 13 ++ 3 files changed, 199 insertions(+), 96 deletions(-) diff --git a/R/content-replay.R b/R/content-replay.R index 10e61cd6..1056e32a 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -70,16 +70,25 @@ contents_record <- ) } + if ( + is_list_of_s7_objects(content) + # (is_unnamed_list(content) || is_named_list(content)) && + # all(map_lgl(content, S7_inherits)) + ) { + # If the content is a list, we need to record each element + # and return a list of the recorded elements + return(lapply(content, contents_record, chat = chat)) + } + recorded <- S7::S7_dispatch() - for (name in c("version", "class", "props")) { - if (!name %in% names(recorded)) { - cli::cli_abort( - "Expected the recorded object to have a {.val {name}} property.", - call = caller_env() - ) - } + if (!is_recorded_object(recorded)) { + cli::cli_abort( + "Expected the recorded object to be a list with at least names 'version', 'class', and 'props'.", + call = caller_env() + ) } + if ( !is.character(recorded$class) || length(recorded$class) != 1 @@ -93,46 +102,81 @@ contents_record <- recorded } ) + +prop_is_read_only <- function(prop) { + is.function(prop$getter) && !is.function(prop$setter) +} +prop_type_is_func <- function(prop) { + all(class(prop) == class(S7::S7_function)) && + prop$class == S7::S7_function$class +} method(contents_record, S7::S7_object) <- function(content, ..., chat) { - prop_names <- S7::prop_names(content) + # Remove read-only properties + cls_props <- S7::S7_class(content)@properties + prop_names <- names(cls_props)[!map_lgl(cls_props, prop_is_read_only)] + class_name <- class(content)[1] - list( - version = 1, - class = class(content)[1], - props = setNames( - lapply(prop_names, function(prop_name) { - prop_value <- S7::prop(prop_name, object = content) - if (S7_inherits(prop_value)) { - contents_record(prop_value, chat = chat) - } else { - prop_value - } - }), - prop_names - ) + + recorded_props <- setNames( + lapply(prop_names, function(prop_name) { + prop_value <- S7::prop(prop_name, object = content) + if (S7_inherits(prop_value)) { + # Recursive call to S7 object + contents_record(prop_value, chat = chat) + } else if ( + is_list_of_s7_objects(prop_value) + # (is_unnamed_list(prop_value) || is_named_list(prop_value)) && + # all(map_lgl(prop_value, S7_inherits)) + ) { + # Make record of each item in prop. + # Do not recurse forever! + lapply(prop_value, contents_record, chat = chat) + } else { + prop_value + } + }), + prop_names ) -} -method(contents_record, Turn) <- function(content, ..., chat) { + + # Remove non-serializable properties + recorded_props <- Filter( + function(x) { + !is.function(x) + }, + recorded_props + ) + list( version = 1, - class = class(content)[1], - props = list( - role = content@role, - contents = lapply(content@contents, contents_record, chat = chat), - json = content@json, - tokens = content@tokens - # text = getter only! - ) + class = class_name, + props = recorded_props ) } + +# method(contents_record, Turn) <- function(content, ..., chat) { +# ret <- contents_record(super(content, S7::S7_object), chat = chat) +# ret$props$text <- NULL +# ret +# # list( +# # version = 1, +# # class = class(content)[1], +# # props = list( +# # role = content@role, +# # contents = lapply(content@contents, contents_record, chat = chat), +# # json = content@json, +# # tokens = content@tokens +# # # text = getter only! +# # ) +# # ) +# } + #' @rdname contents_record #' @export # Holy "Holy Trait" dispatching, Batman! contents_replay <- function( obj, ..., - cls = NULL, chat, env = rlang::caller_env() ) { @@ -150,10 +194,30 @@ contents_replay <- function( if (!is.list(obj)) { return(obj) } - if (!all(c("version", "class", "props") %in% names(obj))) { + + if (!is_recorded_object(obj)) { + if ( + is_list_of_recorded_objects(obj) + # (is_named_list(obj) || is_unnamed_list(obj)) && + # all(map_lgl(obj, is_recorded_object)) + ) { + # If the object is a list, we need to replay each element + # and return a list of the replayed elements + return(lapply(obj, contents_replay, chat = chat, env = env)) + } return(obj) } + # if (!all(c("version", "class", "props") %in% names(obj))) { + # # If the object isn't a recorded object, return it as is + # if (is_named_list(obj)) { + # # If the object is a named list, we need to replay each element + # # and return a list of the replayed elements + # return(lapply(obj, contents_replay, chat = chat, env = env)) + # } + # return(obj) + # } + class_value <- obj$class if (!(is.character(class_value) && length(class_value) > 0)) { return(obj) @@ -232,23 +296,26 @@ method(contents_replay_class, S7::S7_object) <- function( } -method(contents_record, ToolDef) <- function(content, ..., chat) { - list( - version = 1, - class = class(content)[1], - props = list( - name = content@name, - # Do not record the function! - # It is not serializable and will not be neeeded after replay as the _real_ tool would be leveraged. - # However, keep all the other properties as the metadata could be useful. - fun = NULL, - description = content@description, - arguments = contents_record(content@arguments, chat = chat), - convert = content@convert, - annotations = content@annotations - ) - ) -} +# method(contents_record, ToolDef) <- function(content, ..., chat) { +# ret <- contents_record(super(content, S7::S7_object), chat = chat) +# # Do not record the function! +# # It is not serializable and will not be neeeded after replay as the _real_ tool would be leveraged. +# # However, keep all the other properties as the metadata could be useful. +# ret$props$fun <- NULL +# ret +# # list( +# # version = 1, +# # class = class(content)[1], +# # props = list( +# # name = content@name, +# # fun = NULL, +# # description = content@description, +# # arguments = contents_record(content@arguments, chat = chat), +# # convert = content@convert, +# # annotations = content@annotations +# # ) +# # ) +# } method(contents_replay_class, ToolDef) <- function( cls, obj, @@ -282,48 +349,47 @@ method(contents_replay_class, ToolDef) <- function( } -method(contents_record, TypeObject) <- function(content, ..., chat) { - list( - version = 1, - class = class(content)[1], - props = list( - description = content@description, - required = content@required, - properties = lapply( - content@properties, - contents_record, - chat = chat - ), - additional_properties = content@additional_properties - ) - ) -} -method(contents_replay_class, TypeObject) <- function( - cls, - obj, - ..., - chat, - env = rlang::caller_env() -) { - if (obj$version != 1) { - cli::cli_abort( - "Unsupported version {.val {obj$version}}.", - call = caller_env() - ) - } - - TypeObject( - description = obj$props$description, - required = obj$props$required, - properties = lapply( - obj$props$properties, - contents_replay, - chat = chat - ), - additional_properties = obj$props$additional_properties - ) -} - +# method(contents_record, TypeObject) <- function(content, ..., chat) { +# list( +# version = 1, +# class = class(content)[1], +# props = list( +# description = content@description, +# required = content@required, +# properties = lapply( +# content@properties, +# contents_record, +# chat = chat +# ), +# additional_properties = content@additional_properties +# ) +# ) +# } +# method(contents_replay_class, TypeObject) <- function( +# cls, +# obj, +# ..., +# chat, +# env = rlang::caller_env() +# ) { +# if (obj$version != 1) { +# cli::cli_abort( +# "Unsupported version {.val {obj$version}}.", +# call = caller_env() +# ) +# } + +# TypeObject( +# description = obj$props$description, +# required = obj$props$required, +# properties = lapply( +# obj$props$properties, +# contents_replay, +# chat = chat +# ), +# additional_properties = obj$props$additional_properties +# ) +# } #' Retrieve the class constructor #' @@ -362,3 +428,27 @@ get_cls_constructor <- function(class_value, ..., env = rlang::caller_env()) { ) } } + +# is_unnamed_list <- function(x) { +# is.list(x) && +# (all(!rlang::have_name(x))) +# } + +# is_named_list <- function(x) { +# is.list(x) && +# (all(rlang::have_name(x))) +# } +is_recorded_object <- function(x) { + is.list(x) && + all(c("version", "class", "props") %in% names(x)) +} + +is_list_of_s7_objects <- function(x) { + is.list(x) && + all(map_lgl(x, S7_inherits)) +} + +is_list_of_recorded_objects <- function(x) { + is.list(x) && + all(map_lgl(x, is_recorded_object)) +} diff --git a/man/contents_record.Rd b/man/contents_record.Rd index 99d4deb5..9cfda56c 100644 --- a/man/contents_record.Rd +++ b/man/contents_record.Rd @@ -8,7 +8,7 @@ \usage{ contents_record(content, ..., chat) -contents_replay(obj, ..., cls = NULL, chat, env = rlang::caller_env()) +contents_replay(obj, ..., chat, env = rlang::caller_env()) contents_replay_class(cls, obj, ..., chat, env = rlang::caller_env()) } @@ -21,9 +21,9 @@ contents_replay_class(cls, obj, ..., chat, env = rlang::caller_env()) \item{obj}{A basic list (from \code{contents_record()}) to be replayed.} -\item{cls}{The class constructor to be used for replaying the object.} - \item{env}{The environment to find non-package classes.} + +\item{cls}{The class constructor to be used for replaying the object.} } \description{ These generic functions can be use to convert \link{Turn} contents or \link{Content} diff --git a/tests/testthat/test-content-replay.R b/tests/testthat/test-content-replay.R index a19496ae..5f01b380 100644 --- a/tests/testthat/test-content-replay.R +++ b/tests/testthat/test-content-replay.R @@ -1,5 +1,18 @@ # ------------------------------------------------------------------------- +test_that("can round trip of Turn record/replay", { + test_record_replay(Turn("user")) + + test_record_replay(Turn( + "user", + list( + ContentText("hello world"), + ContentText("hello world2") + ) + )) +}) + + test_that("can round trip of Content record/replay", { test_record_replay(Content()) }) From 4493487f4e06bac6079eafe88ace2bef52ace3b9 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 21 May 2025 12:45:04 -0400 Subject: [PATCH 25/34] clean up code and add test for unknown tool --- R/content-replay.R | 154 +++------------------------ tests/testthat/test-content-replay.R | 29 ++++- 2 files changed, 44 insertions(+), 139 deletions(-) diff --git a/R/content-replay.R b/R/content-replay.R index 1056e32a..082fc844 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -70,11 +70,7 @@ contents_record <- ) } - if ( - is_list_of_s7_objects(content) - # (is_unnamed_list(content) || is_named_list(content)) && - # all(map_lgl(content, S7_inherits)) - ) { + if (is_list_of_s7_objects(content)) { # If the content is a list, we need to record each element # and return a list of the recorded elements return(lapply(content, contents_record, chat = chat)) @@ -103,31 +99,20 @@ contents_record <- } ) -prop_is_read_only <- function(prop) { - is.function(prop$getter) && !is.function(prop$setter) -} -prop_type_is_func <- function(prop) { - all(class(prop) == class(S7::S7_function)) && - prop$class == S7::S7_function$class -} method(contents_record, S7::S7_object) <- function(content, ..., chat) { - # Remove read-only properties + class_name <- class(content)[1] + + # Remove read-only props cls_props <- S7::S7_class(content)@properties prop_names <- names(cls_props)[!map_lgl(cls_props, prop_is_read_only)] - class_name <- class(content)[1] - recorded_props <- setNames( lapply(prop_names, function(prop_name) { prop_value <- S7::prop(prop_name, object = content) if (S7_inherits(prop_value)) { # Recursive call to S7 object contents_record(prop_value, chat = chat) - } else if ( - is_list_of_s7_objects(prop_value) - # (is_unnamed_list(prop_value) || is_named_list(prop_value)) && - # all(map_lgl(prop_value, S7_inherits)) - ) { + } else if (is_list_of_s7_objects(prop_value)) { # Make record of each item in prop. # Do not recurse forever! lapply(prop_value, contents_record, chat = chat) @@ -154,23 +139,6 @@ method(contents_record, S7::S7_object) <- function(content, ..., chat) { } -# method(contents_record, Turn) <- function(content, ..., chat) { -# ret <- contents_record(super(content, S7::S7_object), chat = chat) -# ret$props$text <- NULL -# ret -# # list( -# # version = 1, -# # class = class(content)[1], -# # props = list( -# # role = content@role, -# # contents = lapply(content@contents, contents_record, chat = chat), -# # json = content@json, -# # tokens = content@tokens -# # # text = getter only! -# # ) -# # ) -# } - #' @rdname contents_record #' @export # Holy "Holy Trait" dispatching, Batman! @@ -196,28 +164,12 @@ contents_replay <- function( } if (!is_recorded_object(obj)) { - if ( - is_list_of_recorded_objects(obj) - # (is_named_list(obj) || is_unnamed_list(obj)) && - # all(map_lgl(obj, is_recorded_object)) - ) { - # If the object is a list, we need to replay each element - # and return a list of the replayed elements + if (is_list_of_recorded_objects(obj)) { return(lapply(obj, contents_replay, chat = chat, env = env)) } return(obj) } - # if (!all(c("version", "class", "props") %in% names(obj))) { - # # If the object isn't a recorded object, return it as is - # if (is_named_list(obj)) { - # # If the object is a named list, we need to replay each element - # # and return a list of the replayed elements - # return(lapply(obj, contents_replay, chat = chat, env = env)) - # } - # return(obj) - # } - class_value <- obj$class if (!(is.character(class_value) && length(class_value) > 0)) { return(obj) @@ -288,34 +240,10 @@ method(contents_replay_class, S7::S7_object) <- function( # While this seems like a bit of extra work, the tracebacks are accurate # vs referencing an unrelated parameter name in the traceback - rlang::exec( - cls_name, - !!!obj_props, - .env = env - ) + rlang::exec(cls_name, !!!obj_props, .env = env) } -# method(contents_record, ToolDef) <- function(content, ..., chat) { -# ret <- contents_record(super(content, S7::S7_object), chat = chat) -# # Do not record the function! -# # It is not serializable and will not be neeeded after replay as the _real_ tool would be leveraged. -# # However, keep all the other properties as the metadata could be useful. -# ret$props$fun <- NULL -# ret -# # list( -# # version = 1, -# # class = class(content)[1], -# # props = list( -# # name = content@name, -# # fun = NULL, -# # description = content@description, -# # arguments = contents_record(content@arguments, chat = chat), -# # convert = content@convert, -# # annotations = content@annotations -# # ) -# # ) -# } method(contents_replay_class, ToolDef) <- function( cls, obj, @@ -337,60 +265,17 @@ method(contents_replay_class, ToolDef) <- function( return(matched_tool) } - # If no tool is found, return placeholder tool - ToolDef( - name = obj$props$name, - # fun = NULL, # fun was not serialized - description = obj$props$description, - arguments = contents_replay(obj$props$arguments, chat = chat), - convert = obj$props$convert, - annotations = obj$props$annotations + # If no tool is found, return placeholder tool containing the metadata + ret <- contents_replay_class( + super(cls, S7::S7_object), + obj, + chat = chat, + env = env ) + ret } -# method(contents_record, TypeObject) <- function(content, ..., chat) { -# list( -# version = 1, -# class = class(content)[1], -# props = list( -# description = content@description, -# required = content@required, -# properties = lapply( -# content@properties, -# contents_record, -# chat = chat -# ), -# additional_properties = content@additional_properties -# ) -# ) -# } -# method(contents_replay_class, TypeObject) <- function( -# cls, -# obj, -# ..., -# chat, -# env = rlang::caller_env() -# ) { -# if (obj$version != 1) { -# cli::cli_abort( -# "Unsupported version {.val {obj$version}}.", -# call = caller_env() -# ) -# } - -# TypeObject( -# description = obj$props$description, -# required = obj$props$required, -# properties = lapply( -# obj$props$properties, -# contents_replay, -# chat = chat -# ), -# additional_properties = obj$props$additional_properties -# ) -# } - #' Retrieve the class constructor #' #' @description @@ -429,15 +314,10 @@ get_cls_constructor <- function(class_value, ..., env = rlang::caller_env()) { } } -# is_unnamed_list <- function(x) { -# is.list(x) && -# (all(!rlang::have_name(x))) -# } +prop_is_read_only <- function(prop) { + is.function(prop$getter) && !is.function(prop$setter) +} -# is_named_list <- function(x) { -# is.list(x) && -# (all(rlang::have_name(x))) -# } is_recorded_object <- function(x) { is.list(x) && all(c("version", "class", "props") %in% names(x)) diff --git a/tests/testthat/test-content-replay.R b/tests/testthat/test-content-replay.R index 5f01b380..b86c844f 100644 --- a/tests/testthat/test-content-replay.R +++ b/tests/testthat/test-content-replay.R @@ -66,7 +66,7 @@ test_that("can round trip of ContentThinking record/replay", { test_that("can round trip of ContentTool record/replay", { chat <- chat_ollama_test("Be as terse as possible; no punctuation") tool_rnorm <- tool( - stats::rnorm, + rnorm, "Drawn numbers from a random normal distribution", n = type_integer("The number of observations. Must be a positive integer."), mean = type_number("The mean value of the distribution."), @@ -85,7 +85,8 @@ test_that("can round trip of ContentTool record/replay", { test_that("can round trip of ToolDef record/replay", { chat <- chat_ollama_test("Be as terse as possible; no punctuation") tool_rnorm <- tool( - stats::rnorm, + # Use `rnorm` to avoid loading the package... this causes the name to not be auto found + rnorm, "Drawn numbers from a random normal distribution", n = type_integer("The number of observations. Must be a positive integer."), mean = type_number("The mean value of the distribution."), @@ -106,6 +107,30 @@ test_that("can round trip of ToolDef record/replay", { ), chat = chat ) + + recorded_tool <- contents_record(tool_rnorm, chat = chat) + chat_empty <- chat_ollama_test("Be as terse as possible; no punctuation") + replayed_tool <- contents_replay(recorded_tool, chat = chat_empty) + + tool_rnorm_empty <- ToolDef( + # rnorm, + name = "rnorm", + description = "Drawn numbers from a random normal distribution", + arguments = type_object( + n = type_integer( + "The number of observations. Must be a positive integer." + ), + mean = type_number("The mean value of the distribution."), + sd = type_number( + "The standard deviation of the distribution. Must be a non-negative number." + ) + ), + ) + + expect_equal( + replayed_tool, + tool_rnorm_empty + ) }) test_that("can round trip of ContentToolResult record/replay", { From 4a80ad064fe9639d0f3844102bc5ed58851d6f28 Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 9 Jun 2025 10:34:58 -0700 Subject: [PATCH 26/34] Switch tests to use chatgpt --- tests/testthat/helper-content-replay.R | 2 +- tests/testthat/test-content-replay.R | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/testthat/helper-content-replay.R b/tests/testthat/helper-content-replay.R index feaa1040..9f7acfb9 100644 --- a/tests/testthat/helper-content-replay.R +++ b/tests/testthat/helper-content-replay.R @@ -1,7 +1,7 @@ test_record_replay <- function( x, ..., - chat = chat_ollama_test("Be as terse as possible; no punctuation"), + chat = chat_openai_test(), env = rlang::caller_env() ) { rlang::check_dots_empty() diff --git a/tests/testthat/test-content-replay.R b/tests/testthat/test-content-replay.R index b86c844f..61874a46 100644 --- a/tests/testthat/test-content-replay.R +++ b/tests/testthat/test-content-replay.R @@ -64,7 +64,7 @@ test_that("can round trip of ContentThinking record/replay", { }) test_that("can round trip of ContentTool record/replay", { - chat <- chat_ollama_test("Be as terse as possible; no punctuation") + chat <- chat_openai_test() tool_rnorm <- tool( rnorm, "Drawn numbers from a random normal distribution", @@ -83,7 +83,7 @@ test_that("can round trip of ContentTool record/replay", { }) test_that("can round trip of ToolDef record/replay", { - chat <- chat_ollama_test("Be as terse as possible; no punctuation") + chat <- chat_openai_test() tool_rnorm <- tool( # Use `rnorm` to avoid loading the package... this causes the name to not be auto found rnorm, @@ -109,7 +109,7 @@ test_that("can round trip of ToolDef record/replay", { ) recorded_tool <- contents_record(tool_rnorm, chat = chat) - chat_empty <- chat_ollama_test("Be as terse as possible; no punctuation") + chat_empty <- chat_openai_test() replayed_tool <- contents_replay(recorded_tool, chat = chat_empty) tool_rnorm_empty <- ToolDef( @@ -143,7 +143,7 @@ test_that("can round trip of ContentToolResult record/replay", { ) ) - chat <- chat_ollama_test("Be as terse as possible; no punctuation") + chat <- chat_openai_test() tool_rnorm <- tool( stats::rnorm, "Drawn numbers from a random normal distribution", @@ -191,7 +191,7 @@ test_that("can round trip of ContentPDF record/replay", { }) test_that("non-package classes are recorded/replayed by default", { - chat <- chat_ollama_test("Be as terse as possible; no punctuation") + chat <- chat_openai_test() LocalClass <- S7::new_class( "LocalClass", @@ -208,7 +208,7 @@ test_that("non-package classes are recorded/replayed by default", { test_that("unknown classes cause errors", { - chat <- chat_ollama_test("Be as terse as possible; no punctuation") + chat <- chat_openai_test() recorded <- contents_record(Turn("user"), chat = chat) recorded$class <- "ellmer::Turn2" @@ -232,7 +232,7 @@ test_that("replay classes are S7 classes", { OtherName(name = name) } - chat <- chat_ollama_test("Be as terse as possible; no punctuation") + chat <- chat_openai_test() recorded <- contents_record(LocalClass("testname"), chat = chat) expect_error( contents_replay(recorded, chat = chat), From 6feb5410071c9538a82e2902d7bf0a10b119cb3c Mon Sep 17 00:00:00 2001 From: Hadley Wickham Date: Mon, 9 Jun 2025 10:39:04 -0700 Subject: [PATCH 27/34] Tweak docs/style; unexport `contents_replay_class` --- NAMESPACE | 1 - R/content-replay.R | 89 ++++++++++-------------------------------- man/contents_record.Rd | 55 ++++++++------------------ 3 files changed, 37 insertions(+), 108 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 20dc6424..d4b4b1ec 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -58,7 +58,6 @@ export(contents_html) export(contents_markdown) export(contents_record) export(contents_replay) -export(contents_replay_class) export(contents_text) export(create_tool_def) export(google_upload) diff --git a/R/content-replay.R b/R/content-replay.R index 082fc844..42ae30f2 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -8,54 +8,32 @@ NULL #' Save and restore content #' #' @description -#' These generic functions can be use to convert [Turn] contents or [Content] -#' objects into easily serializable representations. +#' These generic functions can be use to convert [Turn]/[Content] objects +#' into easily serializable representations. #' -#' * `contents_record()` will accept a [Turn] or [Content] related objects and return a -#' basic list that can be easily serialized. -#' * `contents_replay()` will accept a basic list (from `contents_record()`) and -#' return a corresponding [Turn] or [Content] related object. -#' * `contents_replay_class()` is a generic function that is dispatched from -#' within `contents_replay()`. `contents_replay()` will retrieve the -#' corresponding contructor class from within the basic list information and -#' use the class for dispatching. +#' * `contents_record()` will accept a [Turn] or [Content] and return a +#' simple list. +#' * `contents_replay()` will accept a simple list (from `contents_record()`) +#' and return a [Turn] or [Content] object. #' -#' Note, all S7 classes should have the same class name as the variable name. Ex: `FooBar <- new_class("FooBar")`, not `OtherName <- new_class("FooBar")`. This is a requirement for when replaying the object. -#' -#' @param content A [Turn] or [Content] object to have its record retrieved. -#' @param obj A basic list (from `contents_record()`) to be replayed. +#' @param content A [Turn] or [Content] object to serialize. +#' @param obj A basic list to desierialize. #' @param cls The class constructor to be used for replaying the object. #' @param chat A [Chat] object to be used for context. #' @param env The environment to find non-package classes. #' @param ... Not used. #' -#' @examples -#' \dontrun{ -#' chat <- chat_ollama(model = "llama3.2") -#' turn <- Turn("user") -#' turn -#' #> +#' @examplesIf has_credentials("openai") +#' chat <- chat_openai(model = "gpt-4.1-nano") +#' chat$chat("Where is the capital of France?") #' -#' # Get the turn's record -#' # Note: Removes all S7 class instances -#' turn_recorded <- contents_record(turn, chat = chat) +#' # Serialize to a simple list +#' turn_recorded <- contents_record(chat$get_turns(), chat = chat) #' str(turn_recorded) -#' #> List of 3 -#' #> $ version: num 1 -#' #> $ class : chr "ellmer::Turn" -#' #> $ props :List of 4 -#' #> ..$ role : chr "user" -#' #> ..$ contents: list() -#' #> ..$ json : list() -#' #> ..$ tokens : num [1:2] 0 0 #' -#' # Restore the turn from the record -#' # Note: This will not restore the _original_ object, -#' # but a new object with the same properties +#' # Deserialize back to S7 objects #' turn_replayed <- contents_replay(turn_recorded, chat = chat) #' turn_replayed -#' #> -#' } #' @export #' @rdname contents_record contents_record <- @@ -63,12 +41,7 @@ contents_record <- "contents_record", "content", function(content, ..., chat) { - if (!(R6::is.R6(chat) && inherits(chat, "Chat"))) { - cli::cli_abort( - "Expected a Chat object at `chat=`, but received {.val {chat}}.", - call = caller_env() - ) - } + check_chat(chat, call = caller_env()) if (is_list_of_s7_objects(content)) { # If the content is a list, we need to record each element @@ -124,12 +97,7 @@ method(contents_record, S7::S7_object) <- function(content, ..., chat) { ) # Remove non-serializable properties - recorded_props <- Filter( - function(x) { - !is.function(x) - }, - recorded_props - ) + recorded_props <- Filter(function(x) !is.function(x), recorded_props) list( version = 1, @@ -142,18 +110,8 @@ method(contents_record, S7::S7_object) <- function(content, ..., chat) { #' @rdname contents_record #' @export # Holy "Holy Trait" dispatching, Batman! -contents_replay <- function( - obj, - ..., - chat, - env = rlang::caller_env() -) { - if (!(R6::is.R6(chat) && inherits(chat, "Chat"))) { - cli::cli_abort( - "Expected a Chat object at `chat=`, but received {.val {chat}}.", - call = caller_env() - ) - } +contents_replay <- function(obj, ..., chat, env = caller_env()) { + check_chat(chat, call = caller_env()) # Find any reason to not believe `obj` is a recorded object. # If not a recorded object, return it as is. @@ -199,8 +157,6 @@ contents_replay <- function( handler(cls, obj, chat = chat, env = env) } -#' @rdname contents_record -#' @export contents_replay_class <- new_generic( "contents_replay_class", "cls", @@ -319,16 +275,13 @@ prop_is_read_only <- function(prop) { } is_recorded_object <- function(x) { - is.list(x) && - all(c("version", "class", "props") %in% names(x)) + is.list(x) && all(c("version", "class", "props") %in% names(x)) } is_list_of_s7_objects <- function(x) { - is.list(x) && - all(map_lgl(x, S7_inherits)) + is.list(x) && all(map_lgl(x, S7_inherits)) } is_list_of_recorded_objects <- function(x) { - is.list(x) && - all(map_lgl(x, is_recorded_object)) + is.list(x) && all(map_lgl(x, is_recorded_object)) } diff --git a/man/contents_record.Rd b/man/contents_record.Rd index 9cfda56c..d1218de2 100644 --- a/man/contents_record.Rd +++ b/man/contents_record.Rd @@ -3,69 +3,46 @@ \name{contents_record} \alias{contents_record} \alias{contents_replay} -\alias{contents_replay_class} \title{Save and restore content} \usage{ contents_record(content, ..., chat) -contents_replay(obj, ..., chat, env = rlang::caller_env()) - -contents_replay_class(cls, obj, ..., chat, env = rlang::caller_env()) +contents_replay(obj, ..., chat, env = caller_env()) } \arguments{ -\item{content}{A \link{Turn} or \link{Content} object to have its record retrieved.} +\item{content}{A \link{Turn} or \link{Content} object to serialize.} \item{...}{Not used.} \item{chat}{A \link{Chat} object to be used for context.} -\item{obj}{A basic list (from \code{contents_record()}) to be replayed.} +\item{obj}{A basic list to desierialize.} \item{env}{The environment to find non-package classes.} \item{cls}{The class constructor to be used for replaying the object.} } \description{ -These generic functions can be use to convert \link{Turn} contents or \link{Content} -objects into easily serializable representations. +These generic functions can be use to convert \link{Turn}/\link{Content} objects +into easily serializable representations. \itemize{ -\item \code{contents_record()} will accept a \link{Turn} or \link{Content} related objects and return a -basic list that can be easily serialized. -\item \code{contents_replay()} will accept a basic list (from \code{contents_record()}) and -return a corresponding \link{Turn} or \link{Content} related object. -\item \code{contents_replay_class()} is a generic function that is dispatched from -within \code{contents_replay()}. \code{contents_replay()} will retrieve the -corresponding contructor class from within the basic list information and -use the class for dispatching. +\item \code{contents_record()} will accept a \link{Turn} or \link{Content} and return a +simple list. +\item \code{contents_replay()} will accept a simple list (from \code{contents_record()}) +and return a \link{Turn} or \link{Content} object. } - -Note, all S7 classes should have the same class name as the variable name. Ex: \code{FooBar <- new_class("FooBar")}, not \code{OtherName <- new_class("FooBar")}. This is a requirement for when replaying the object. } \examples{ -\dontrun{ -chat <- chat_ollama(model = "llama3.2") -turn <- Turn("user") -turn -#> +\dontshow{if (has_credentials("openai")) (if (getRversion() >= "3.4") withAutoprint else force)(\{ # examplesIf} +chat <- chat_openai(model = "gpt-4.1-nano") +chat$chat("Where is the capital of France?") -# Get the turn's record -# Note: Removes all S7 class instances -turn_recorded <- contents_record(turn, chat = chat) +# Serialize to a simple list +turn_recorded <- contents_record(chat$get_turns(), chat = chat) str(turn_recorded) -#> List of 3 -#> $ version: num 1 -#> $ class : chr "ellmer::Turn" -#> $ props :List of 4 -#> ..$ role : chr "user" -#> ..$ contents: list() -#> ..$ json : list() -#> ..$ tokens : num [1:2] 0 0 -# Restore the turn from the record -# Note: This will not restore the _original_ object, -# but a new object with the same properties +# Deserialize back to S7 objects turn_replayed <- contents_replay(turn_recorded, chat = chat) turn_replayed -#> -} +\dontshow{\}) # examplesIf} } From 20b961ec20f25f8c46980ed48bc8cd3f3fadb7fa Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 9 Jun 2025 14:00:11 -0400 Subject: [PATCH 28/34] doc update Co-Authored-By: Hadley Wickham --- R/content-replay.R | 3 +-- man/contents_record.Rd | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/R/content-replay.R b/R/content-replay.R index 42ae30f2..ef3d1848 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -11,8 +11,7 @@ NULL #' These generic functions can be use to convert [Turn]/[Content] objects #' into easily serializable representations. #' -#' * `contents_record()` will accept a [Turn] or [Content] and return a -#' simple list. +#' * `contents_record()` accept a [Turn] or [Content] and return a simple list. #' * `contents_replay()` will accept a simple list (from `contents_record()`) #' and return a [Turn] or [Content] object. #' diff --git a/man/contents_record.Rd b/man/contents_record.Rd index d1218de2..f38a615e 100644 --- a/man/contents_record.Rd +++ b/man/contents_record.Rd @@ -26,8 +26,7 @@ contents_replay(obj, ..., chat, env = caller_env()) These generic functions can be use to convert \link{Turn}/\link{Content} objects into easily serializable representations. \itemize{ -\item \code{contents_record()} will accept a \link{Turn} or \link{Content} and return a -simple list. +\item \code{contents_record()} accept a \link{Turn} or \link{Content} and return a simple list. \item \code{contents_replay()} will accept a simple list (from \code{contents_record()}) and return a \link{Turn} or \link{Content} object. } From f41cc3c2552839186ce4872ce196b264357e2702 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 9 Jun 2025 14:03:46 -0400 Subject: [PATCH 29/34] Remove prefix of `rlang::` --- R/content-replay.R | 22 +++++++++++----------- R/tools-def.R | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/R/content-replay.R b/R/content-replay.R index ef3d1848..b0f3788a 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -159,7 +159,7 @@ contents_replay <- function(obj, ..., chat, env = caller_env()) { contents_replay_class <- new_generic( "contents_replay_class", "cls", - function(cls, obj, ..., chat, env = rlang::caller_env()) { + function(cls, obj, ..., chat, env = caller_env()) { S7::S7_dispatch() } ) @@ -170,7 +170,7 @@ method(contents_replay_class, S7::S7_object) <- function( obj, ..., chat, - env = rlang::caller_env() + env = caller_env() ) { stopifnot(obj$version == 1) @@ -183,19 +183,19 @@ method(contents_replay_class, S7::S7_object) <- function( pkg_cls <- strsplit(class_value, "::")[[1]] pkg_name <- pkg_cls[1] - rlang::check_installed( + check_installed( pkg_name, reason = "for `contents_replay()` to restore the chat content." ) cls_name <- pkg_cls[2] - env <- rlang::ns_env(pkg_name) + env <- ns_env(pkg_name) } else { cls_name <- class_value } # While this seems like a bit of extra work, the tracebacks are accurate # vs referencing an unrelated parameter name in the traceback - rlang::exec(cls_name, !!!obj_props, .env = env) + exec(cls_name, !!!obj_props, .env = env) } @@ -204,7 +204,7 @@ method(contents_replay_class, ToolDef) <- function( obj, ..., chat, - env = rlang::caller_env() + env = caller_env() ) { if (obj$version != 1) { cli::cli_abort( @@ -244,23 +244,23 @@ method(contents_replay_class, ToolDef) <- function( #' @param env The environment to find non-package class constructors. #' @return The constructor function for the class. #' @noRd -get_cls_constructor <- function(class_value, ..., env = rlang::caller_env()) { - rlang::check_dots_empty() +get_cls_constructor <- function(class_value, ..., env = caller_env()) { + check_dots_empty() pkg_cls <- strsplit(class_value, "::")[[1]] if (length(pkg_cls) == 1) { # If the class is not a package class, return the object as is # This is the case for local S7 objects - rlang::eval_bare(rlang::sym(pkg_cls), env = env) + eval_bare(sym(pkg_cls), env = env) } else if (length(pkg_cls) == 2) { pkg_name <- pkg_cls[1] cls_name <- pkg_cls[2] - rlang::check_installed( + check_installed( pkg_name, reason = "for `contents_replay()` to restore the chat content." ) - rlang::ns_env(pkg_name)[[cls_name]] + ns_env(pkg_name)[[cls_name]] } else { cli::cli_abort( "Invalid class name {.val {class_value[1]}}. Expected a single (or missing) `::` separator, not multiple.", diff --git a/R/tools-def.R b/R/tools-def.R index 5192eff1..5e746ff7 100644 --- a/R/tools-def.R +++ b/R/tools-def.R @@ -264,7 +264,7 @@ tool_reject <- function( ) { check_string(reason) - rlang::abort( + abort( paste("Tool call rejected.", reason), class = "ellmer_tool_reject" ) From 81dbaa617f0c6a2fcaaaff57379c7d8945803a02 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 9 Jun 2025 14:18:27 -0400 Subject: [PATCH 30/34] Remove unnecessary arg to `cli_abort()`. Add snapshot of error. --- R/content-replay.R | 20 ++++++-------------- tests/testthat/_snaps/content-replay.md | 16 ++++++++++++++++ tests/testthat/test-content-replay.R | 4 ++++ 3 files changed, 26 insertions(+), 14 deletions(-) create mode 100644 tests/testthat/_snaps/content-replay.md diff --git a/R/content-replay.R b/R/content-replay.R index b0f3788a..909d4ded 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -52,8 +52,7 @@ contents_record <- if (!is_recorded_object(recorded)) { cli::cli_abort( - "Expected the recorded object to be a list with at least names 'version', 'class', and 'props'.", - call = caller_env() + "Expected the recorded object to be a list with at least names 'version', 'class', and 'props'." ) } @@ -62,8 +61,7 @@ contents_record <- length(recorded$class) != 1 ) { cli::cli_abort( - "Expected the recorded object to have a single $class name, containing `::` if the class is from a package.", - call = caller_env() + "Expected the recorded object to have a single $class name, containing `::` if the class is from a package." ) } @@ -135,16 +133,12 @@ contents_replay <- function(obj, ..., chat, env = caller_env()) { cls <- get_cls_constructor(class_value[1], env = env) if (is.null(cls)) { - cli::cli_abort( - "Unable to find the S7 class: {.val {class_value[1]}}.", - call = caller_env() - ) + cli::cli_abort("Unable to find the S7 class: {.val {class_value[1]}}.") } if (!S7_inherits(cls)) { cli::cli_abort( - "The object returned for {.val {class_value[1]}} is not an S7 class.", - call = caller_env() + "The object returned for {.val {class_value[1]}} is not an S7 class." ) } @@ -208,8 +202,7 @@ method(contents_replay_class, ToolDef) <- function( ) { if (obj$version != 1) { cli::cli_abort( - "Unsupported version {.val {obj$version}}.", - call = caller_env() + "Unsupported version {.val {obj$version}}." ) } @@ -263,8 +256,7 @@ get_cls_constructor <- function(class_value, ..., env = caller_env()) { ns_env(pkg_name)[[cls_name]] } else { cli::cli_abort( - "Invalid class name {.val {class_value[1]}}. Expected a single (or missing) `::` separator, not multiple.", - call = caller_env() + "Invalid class name {.val {class_value[1]}}. Expected a single (or missing) `::` separator, not multiple." ) } } diff --git a/tests/testthat/_snaps/content-replay.md b/tests/testthat/_snaps/content-replay.md new file mode 100644 index 00000000..2d847891 --- /dev/null +++ b/tests/testthat/_snaps/content-replay.md @@ -0,0 +1,16 @@ +# unknown classes cause errors + + Code + contents_replay(recorded, chat = chat) + Condition + Error in `contents_replay()`: + ! Unable to find the S7 class: "ellmer::Turn2". + +# replay classes are S7 classes + + Code + contents_replay(recorded, chat = chat) + Condition + Error in `contents_replay()`: + ! The object returned for "LocalClass" is not an S7 class. + diff --git a/tests/testthat/test-content-replay.R b/tests/testthat/test-content-replay.R index 61874a46..5d0f0308 100644 --- a/tests/testthat/test-content-replay.R +++ b/tests/testthat/test-content-replay.R @@ -216,6 +216,8 @@ test_that("unknown classes cause errors", { contents_replay(recorded, chat = chat), "Unable to find the S7 class" ) + + expect_snapshot(contents_replay(recorded, chat = chat), error = TRUE) }) test_that("replay classes are S7 classes", { @@ -238,4 +240,6 @@ test_that("replay classes are S7 classes", { contents_replay(recorded, chat = chat), "is not an S7 class" ) + + expect_snapshot(contents_replay(recorded, chat = chat), error = TRUE) }) From 9bbab27abf3bcea8f4c262343376bfbe03512df5 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 9 Jun 2025 14:30:23 -0400 Subject: [PATCH 31/34] Move `cls` assertions inside `get_cls_constructor()` --- R/content-replay.R | 25 ++++++++++++------------- tests/testthat/_snaps/content-replay.md | 4 ++-- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/R/content-replay.R b/R/content-replay.R index 909d4ded..f1d89be4 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -131,17 +131,6 @@ contents_replay <- function(obj, ..., chat, env = caller_env()) { } cls <- get_cls_constructor(class_value[1], env = env) - - if (is.null(cls)) { - cli::cli_abort("Unable to find the S7 class: {.val {class_value[1]}}.") - } - - if (!S7_inherits(cls)) { - cli::cli_abort( - "The object returned for {.val {class_value[1]}} is not an S7 class." - ) - } - # Manually retrieve the handler for the class as we dispatch on the class itself, # not on an instance # An error will be thrown if a method is not found, @@ -244,7 +233,7 @@ get_cls_constructor <- function(class_value, ..., env = caller_env()) { if (length(pkg_cls) == 1) { # If the class is not a package class, return the object as is # This is the case for local S7 objects - eval_bare(sym(pkg_cls), env = env) + cls <- eval_bare(sym(pkg_cls), env = env) } else if (length(pkg_cls) == 2) { pkg_name <- pkg_cls[1] cls_name <- pkg_cls[2] @@ -253,12 +242,22 @@ get_cls_constructor <- function(class_value, ..., env = caller_env()) { pkg_name, reason = "for `contents_replay()` to restore the chat content." ) - ns_env(pkg_name)[[cls_name]] + cls <- ns_env(pkg_name)[[cls_name]] } else { cli::cli_abort( "Invalid class name {.val {class_value[1]}}. Expected a single (or missing) `::` separator, not multiple." ) } + if (is.null(cls)) { + cli::cli_abort("Unable to find the S7 class: {.val {class_value[1]}}.") + } + + if (!S7_inherits(cls)) { + cli::cli_abort( + "The object returned for {.val {class_value[1]}} is not an S7 class." + ) + } + cls } prop_is_read_only <- function(prop) { diff --git a/tests/testthat/_snaps/content-replay.md b/tests/testthat/_snaps/content-replay.md index 2d847891..d2dc4f0a 100644 --- a/tests/testthat/_snaps/content-replay.md +++ b/tests/testthat/_snaps/content-replay.md @@ -3,7 +3,7 @@ Code contents_replay(recorded, chat = chat) Condition - Error in `contents_replay()`: + Error in `get_cls_constructor()`: ! Unable to find the S7 class: "ellmer::Turn2". # replay classes are S7 classes @@ -11,6 +11,6 @@ Code contents_replay(recorded, chat = chat) Condition - Error in `contents_replay()`: + Error in `get_cls_constructor()`: ! The object returned for "LocalClass" is not an S7 class. From 9f52544eb67a8c28dab74dbf5839b5d3c5f45afe Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 9 Jun 2025 14:45:11 -0400 Subject: [PATCH 32/34] Remove non-exported param docs --- R/content-replay.R | 1 - man/contents_record.Rd | 2 -- 2 files changed, 3 deletions(-) diff --git a/R/content-replay.R b/R/content-replay.R index f1d89be4..7525f5b4 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -17,7 +17,6 @@ NULL #' #' @param content A [Turn] or [Content] object to serialize. #' @param obj A basic list to desierialize. -#' @param cls The class constructor to be used for replaying the object. #' @param chat A [Chat] object to be used for context. #' @param env The environment to find non-package classes. #' @param ... Not used. diff --git a/man/contents_record.Rd b/man/contents_record.Rd index f38a615e..b7f2a925 100644 --- a/man/contents_record.Rd +++ b/man/contents_record.Rd @@ -19,8 +19,6 @@ contents_replay(obj, ..., chat, env = caller_env()) \item{obj}{A basic list to desierialize.} \item{env}{The environment to find non-package classes.} - -\item{cls}{The class constructor to be used for replaying the object.} } \description{ These generic functions can be use to convert \link{Turn}/\link{Content} objects From 3465cb444c7a424894d8a16b6f3b2b3086dc6064 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 9 Jun 2025 16:05:31 -0400 Subject: [PATCH 33/34] Only support (and enforce) ellmer s7 objects. --- R/content-replay.R | 153 +++++++++++++++-------------------------- man/contents_record.Rd | 4 +- 2 files changed, 55 insertions(+), 102 deletions(-) diff --git a/R/content-replay.R b/R/content-replay.R index 7525f5b4..881a2859 100644 --- a/R/content-replay.R +++ b/R/content-replay.R @@ -2,7 +2,6 @@ #' @include turns.R #' @include tools-def.R #' @include content.R - NULL #' Save and restore content @@ -18,7 +17,6 @@ NULL #' @param content A [Turn] or [Content] object to serialize. #' @param obj A basic list to desierialize. #' @param chat A [Chat] object to be used for context. -#' @param env The environment to find non-package classes. #' @param ... Not used. #' #' @examplesIf has_credentials("openai") @@ -41,12 +39,6 @@ contents_record <- function(content, ..., chat) { check_chat(chat, call = caller_env()) - if (is_list_of_s7_objects(content)) { - # If the content is a list, we need to record each element - # and return a list of the recorded elements - return(lapply(content, contents_record, chat = chat)) - } - recorded <- S7::S7_dispatch() if (!is_recorded_object(recorded)) { @@ -64,6 +56,12 @@ contents_record <- ) } + if (!grepl("ellmer::", recorded$class, fixed = TRUE)) { + cli::cli_abort( + "Only S7 classes from the `ellmer` package are currently supported. Received: {.val {recorded$class}}." + ) + } + recorded } ) @@ -79,11 +77,10 @@ method(contents_record, S7::S7_object) <- function(content, ..., chat) { lapply(prop_names, function(prop_name) { prop_value <- S7::prop(prop_name, object = content) if (S7_inherits(prop_value)) { - # Recursive call to S7 object + # Recursive record for S7 objects contents_record(prop_value, chat = chat) } else if (is_list_of_s7_objects(prop_value)) { - # Make record of each item in prop. - # Do not recurse forever! + # Make record of each item in list lapply(prop_value, contents_record, chat = chat) } else { prop_value @@ -106,42 +103,57 @@ method(contents_record, S7::S7_object) <- function(content, ..., chat) { #' @rdname contents_record #' @export # Holy "Holy Trait" dispatching, Batman! -contents_replay <- function(obj, ..., chat, env = caller_env()) { +contents_replay <- function(obj, ..., chat) { check_chat(chat, call = caller_env()) # Find any reason to not believe `obj` is a recorded object. # If not a recorded object, return it as is. # If it is a recorded s7 object, dispatch on the discovered class. - if (!is.list(obj)) { - return(obj) + if (!is_recorded_object(obj)) { + cli::cli_abort( + "Expected the object to be a list with at least names 'version', 'class', and 'props'." + ) } - if (!is_recorded_object(obj)) { - if (is_list_of_recorded_objects(obj)) { - return(lapply(obj, contents_replay, chat = chat, env = env)) - } - return(obj) + class_name <- obj$class + if (!(is.character(class_name) && length(class_name) == 1)) { + cli::cli_abort( + "Expected the replay object's `'class'` value to be a single character." + ) + } + + cls_name <- strsplit(class_name, "::")[[1]][2] + if (!grepl("ellmer::", class_name, fixed = TRUE)) { + cli::cli_abort( + "Only S7 classes from the `ellmer` package are currently supported." + ) + } + + cls <- pkg_env("ellmer")[[cls_name]] + + if (is.null(cls)) { + cli::cli_abort("Unable to find the S7 class: {.val {class_name}}.") } - class_value <- obj$class - if (!(is.character(class_value) && length(class_value) > 0)) { - return(obj) + if (!S7_inherits(cls)) { + cli::cli_abort( + "The object returned for {.val {class_name}} is not an S7 class." + ) } - cls <- get_cls_constructor(class_value[1], env = env) # Manually retrieve the handler for the class as we dispatch on the class itself, # not on an instance # An error will be thrown if a method is not found, # however we have a fallback for the `S7::S7_object` (the root base class) handler <- S7::method(contents_replay_class, cls) - handler(cls, obj, chat = chat, env = env) + handler(cls, obj, chat = chat) } contents_replay_class <- new_generic( "contents_replay_class", "cls", - function(cls, obj, ..., chat, env = caller_env()) { + function(cls, obj, ..., chat) { S7::S7_dispatch() } ) @@ -151,42 +163,34 @@ method(contents_replay_class, S7::S7_object) <- function( cls, obj, ..., - chat, - env = caller_env() + chat ) { stopifnot(obj$version == 1) - obj_props <- lapply(obj$props, contents_replay, chat = chat) - - class_value <- obj$class[1] - if (grepl("::", class_value, fixed = TRUE)) { - # If the class is a package class, use the package name to find the constructor - # This allows use to reach into private namespaces within the package - pkg_cls <- strsplit(class_value, "::")[[1]] - pkg_name <- pkg_cls[1] - - check_installed( - pkg_name, - reason = "for `contents_replay()` to restore the chat content." - ) - cls_name <- pkg_cls[2] - env <- ns_env(pkg_name) - } else { - cls_name <- class_value - } + obj_props <- map(obj$props, function(prop_value) { + if (is_list_of_recorded_objects(prop_value)) { + # If the prop is a list of recorded objects, replay each one + map(prop_value, contents_replay, chat = chat) + } else if (is_recorded_object(prop_value)) { + # If the prop is a recorded object, replay it + contents_replay(prop_value, chat = chat) + } else { + prop_value + } + }) + class_name <- obj$class[1] + cls_name <- strsplit(class_name, "::")[[1]][2] # While this seems like a bit of extra work, the tracebacks are accurate # vs referencing an unrelated parameter name in the traceback - exec(cls_name, !!!obj_props, .env = env) + exec(cls_name, !!!obj_props, .env = ns_env("ellmer")) } - method(contents_replay_class, ToolDef) <- function( cls, obj, ..., - chat, - env = caller_env() + chat ) { if (obj$version != 1) { cli::cli_abort( @@ -205,60 +209,11 @@ method(contents_replay_class, ToolDef) <- function( ret <- contents_replay_class( super(cls, S7::S7_object), obj, - chat = chat, - env = env + chat = chat ) ret } - -#' Retrieve the class constructor -#' -#' @description -#' The class for S7 Classes are stored as "package::class" in the recorded object. -#' However, it does not mean that the class constructor is exported in the namespace. -#' Therefore, this function will reach into the package environment to retrieve the constructor. -#' If the class is not a _package_ class, it will return the object as is given the `env`. -#' -#' @param class_value The single string representing the `package::class` to retrieve the constructor. -#' @param ... Not used. -#' @param env The environment to find non-package class constructors. -#' @return The constructor function for the class. -#' @noRd -get_cls_constructor <- function(class_value, ..., env = caller_env()) { - check_dots_empty() - - pkg_cls <- strsplit(class_value, "::")[[1]] - if (length(pkg_cls) == 1) { - # If the class is not a package class, return the object as is - # This is the case for local S7 objects - cls <- eval_bare(sym(pkg_cls), env = env) - } else if (length(pkg_cls) == 2) { - pkg_name <- pkg_cls[1] - cls_name <- pkg_cls[2] - - check_installed( - pkg_name, - reason = "for `contents_replay()` to restore the chat content." - ) - cls <- ns_env(pkg_name)[[cls_name]] - } else { - cli::cli_abort( - "Invalid class name {.val {class_value[1]}}. Expected a single (or missing) `::` separator, not multiple." - ) - } - if (is.null(cls)) { - cli::cli_abort("Unable to find the S7 class: {.val {class_value[1]}}.") - } - - if (!S7_inherits(cls)) { - cli::cli_abort( - "The object returned for {.val {class_value[1]}} is not an S7 class." - ) - } - cls -} - prop_is_read_only <- function(prop) { is.function(prop$getter) && !is.function(prop$setter) } diff --git a/man/contents_record.Rd b/man/contents_record.Rd index b7f2a925..f8c9d3d5 100644 --- a/man/contents_record.Rd +++ b/man/contents_record.Rd @@ -7,7 +7,7 @@ \usage{ contents_record(content, ..., chat) -contents_replay(obj, ..., chat, env = caller_env()) +contents_replay(obj, ..., chat) } \arguments{ \item{content}{A \link{Turn} or \link{Content} object to serialize.} @@ -17,8 +17,6 @@ contents_replay(obj, ..., chat, env = caller_env()) \item{chat}{A \link{Chat} object to be used for context.} \item{obj}{A basic list to desierialize.} - -\item{env}{The environment to find non-package classes.} } \description{ These generic functions can be use to convert \link{Turn}/\link{Content} objects From eefd37f4f4b422d012d8c5b3f71028aa8b383b92 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 9 Jun 2025 16:07:24 -0400 Subject: [PATCH 34/34] Update test for (now unsupported) local s7 class Also remove now _difficult to test_ "returns non-s7 object" from `contents_replay()` --- tests/testthat/_snaps/content-replay.md | 23 ++++++++++++++++------- tests/testthat/test-content-replay.R | 19 ++++++++++++++++--- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/tests/testthat/_snaps/content-replay.md b/tests/testthat/_snaps/content-replay.md index d2dc4f0a..4846271c 100644 --- a/tests/testthat/_snaps/content-replay.md +++ b/tests/testthat/_snaps/content-replay.md @@ -1,16 +1,25 @@ -# unknown classes cause errors +# non-ellmer classes are recorded/replayed by default Code - contents_replay(recorded, chat = chat) + contents_record(LocalClass("testname"), chat = chat) Condition - Error in `get_cls_constructor()`: - ! Unable to find the S7 class: "ellmer::Turn2". + Error in `contents_record()`: + ! Only S7 classes from the `ellmer` package are currently supported. Received: "LocalClass". -# replay classes are S7 classes +--- + + Code + contents_replay(list(version = 1, class = "testpkg::LocalClass", props = list( + name = "testname")), chat = chat) + Condition + Error in `contents_replay()`: + ! Only S7 classes from the `ellmer` package are currently supported. + +# unknown classes cause errors Code contents_replay(recorded, chat = chat) Condition - Error in `get_cls_constructor()`: - ! The object returned for "LocalClass" is not an S7 class. + Error in `contents_replay()`: + ! Unable to find the S7 class: "ellmer::Turn2". diff --git a/tests/testthat/test-content-replay.R b/tests/testthat/test-content-replay.R index 5d0f0308..b72a872e 100644 --- a/tests/testthat/test-content-replay.R +++ b/tests/testthat/test-content-replay.R @@ -190,7 +190,7 @@ test_that("can round trip of ContentPDF record/replay", { test_record_replay(ContentPDF(type = "TYPE", data = "DATA")) }) -test_that("non-package classes are recorded/replayed by default", { +test_that("non-ellmer classes are not recorded/replayed by default", { chat <- chat_openai_test() LocalClass <- S7::new_class( @@ -203,10 +203,23 @@ test_that("non-package classes are recorded/replayed by default", { package = NULL ) - test_record_replay(LocalClass("testname"), chat = chat) + expect_snapshot( + contents_record(LocalClass("testname"), chat = chat), + error = TRUE + ) + expect_snapshot( + contents_replay( + list( + version = 1, + class = "testpkg::LocalClass", + props = list(name = "testname") + ), + chat = chat + ), + error = TRUE + ) }) - test_that("unknown classes cause errors", { chat <- chat_openai_test() recorded <- contents_record(Turn("user"), chat = chat)