Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg-r/DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Imports:
duckdb (>= 1.5.4.2),
ellmer (>= 0.4.1),
evaluate,
filelock,
glue,
highr,
htmltools,
Expand Down
1 change: 1 addition & 0 deletions pkg-r/NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

export(commons)
export(commons_app)
export(commons_prewarm)
export(commons_server)
export(commons_theme)
export(context_layer)
Expand Down
142 changes: 134 additions & 8 deletions pkg-r/R/chat.R
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,7 @@ commons_server <- function(id, client, ...) {
attributes = list("commons.server.id" = id)
)

# Build the context index and start the background pin-cache download
# during post-startup idle time (while the user reads the welcome message).
# Errors are swallowed: the first search retries the index build and
# surfaces the failure to the model, and an unwarmed pin is simply
# downloaded at its first use.
later::later(function() {
tryCatch(client$prewarm(), error = function(err) NULL)
})
prewarm_on_idle(client)

chat <- shinychat::chat_server(id, client = client, ...)
# shinychat owns the conversation identity (it sets the client's
Expand All @@ -130,6 +123,139 @@ commons_server <- function(id, client, ...) {
chat
}

#' Pre-warm a commons agent's caches ahead of deployment
#'
#' A [commons()] agent does some expensive setup the first time it needs to:
#' building the search index over the context layer and downloading any
#' uncached pins (see [data_source()]). When you serve the agent with
#' [commons_server()] or [commons_app()], this warming already happens
#' automatically during post-startup idle time, so the first question is
#' (hopefully) fast — you (hopefully) don't need to call
#' `commons_prewarm()` yourself.
#'
#' The reason to call it directly is to warm the caches *without* running
#' the app. Both kinds of setup are cached on disk — the context index is
#' built once per version of your context documents, and pins are
#' downloaded once into the local pins cache — so running
#' `commons_prewarm(agent, cache_dir)` in a script before deploying lets
#' the deployed app start warm.
#'
#' Pre-warming is a pure optimization — anything it builds is rebuilt on
#' demand if it's missing — so failures are reported as warnings rather
#' than errors. If a pre-deploy script should fail the deploy when warming
#' fails, call the agent's `prewarm()` method directly instead; it lets
#' errors propagate.
#'
#' @section Cache configuration:
#' You can usually ignore this section: by default the context index cache
#' lives in a per-user directory that does the right thing locally and on
#' most hosted platforms. The reasons to configure it are:
#'
#' * **Persistence across deployments on ephemeral hosts.** This is
#' already handled on Connect and Shiny Server: the cache automatically
#' lands in Connect's persistent data directory when the server provides
#' one (currently an early-access feature the administrator enables), or
#' in an `app_cache/` directory beside the app, which survives
#' redeploys. But on hosts where no local disk persists (e.g. Connect
#' Cloud, which resets disk to the deployed bundle and never sets a data
#' directory), the cache is rebuilt after every redeploy unless you
#' point it at persistent storage yourself.
#' * **Shipping a warm cache with the app.** Run
#' `commons_prewarm(agent, cache_dir = "path/inside/the/app")` before
#' deploying, and the deployed bundle includes the pre-built index.
#' `cache_dir` is required for this reason — anything resolved
#' implicitly (a per-user cache directory) would not ship with the
#' deployment.
#' * **Development loops.** If you're editing context documents and want
#' each change re-indexed from scratch, disable persistence with
#' `options(commons.context_cache = FALSE)`.
#'
#' Set the directory with `options(commons.context_cache = "path/to/dir")`
#' or the `COMMONS_CONTEXT_CACHE` environment variable. The cache is
#' capped at 256 MB with least-recently-used eviction; raise
#' `options(commons.context_cache_max_size)` (in bytes) if you index very
#' large context.
#'
#' @param client A [commons()] agent.
#' @param cache_dir A directory for the context index cache, used for this
#' call only (equivalent to setting
#' `options(commons.context_cache = cache_dir)` around it). Point it at
#' a directory inside the app so the warmed index ships with the
#' deployment. Note that rsconnect excludes `app_cache/` from deployed
#' bundles, so pick another name.
#'
#' @return `NULL`, invisibly.
#'
#' @examples
#' \dontrun{
#' # In a pre-deploy script: warm the caches into a directory inside the
#' # app, so the deployed bundle includes the pre-built context index
#' agent <- commons(
#' ellmer::chat_anthropic(),
#' data_sources = data_source(sales = sales)
#' )
#' # (not app_cache/, which rsconnect excludes from the bundle)
#' commons_prewarm(agent, cache_dir = "commons-cache")
#' }
#'
#' @export
commons_prewarm <- function(client, cache_dir) {
check_commons_client(client)
if (missing(cache_dir)) {
cli::cli_abort(c(
"{.arg cache_dir} is required.",
i = "Point it at a directory inside the app (e.g. {.code \"commons-cache\"}) so the warmed cache ships with the deployment."
))
}
if (!rlang::is_string(cache_dir)) {
cli::cli_abort("{.arg cache_dir} must be a path to a cache directory.")
}
tryCatch(
{
withr::with_options(list(commons.context_cache = cache_dir), {
client$prewarm()
prewarm_cache_hint(cache_dir)
})
},
error = function(err) {
msg <- conditionMessage(err)
cli::cli_warn("{msg}")
}
)
invisible(NULL)
}

# An error escaping a later::later() callback would stop the app, so
# downgrade failures to warnings.
prewarm_on_idle <- function(client) {
later::later(function() {
tryCatch(
client$prewarm(),
error = function(err) {
msg <- conditionMessage(err)
cli::cli_warn("{msg}")
}
)
})
invisible(NULL)
}

prewarm_cache_hint <- function(cache_dir) {
store_dir <- context_store_dir(cache_dir)
if (!dir.exists(store_dir)) {
cli::cli_warn(c(
"No context index was cached at {.path {cache_dir}}.",
i = "The agent has no context layer to index, so the deployed app has nothing to reuse."
))
return(invisible())
}
cli::cli_inform(c(
"Warmed the context index cache at {.path {store_dir}}.",
i = "To reuse it, deploy the directory with the app and set {.code options(commons.context_cache = \"{cache_dir}\")} in the app, or the {.envvar COMMONS_CONTEXT_CACHE} environment variable on the server."
))
invisible()
}

check_chat_packages <- function(call = rlang::caller_env()) {
missing <- c("htmltools", "shiny", "shinychat")[
!vapply(
Expand Down
29 changes: 25 additions & 4 deletions pkg-r/R/commons.R
Original file line number Diff line number Diff line change
Expand Up @@ -390,25 +390,46 @@ Commons <- R6::R6Class(
},

prewarm = function() {
# A direct call is typically warming caches ahead of deployment, so
# failures propagate: a cold cache should fail the deploy.
# commons_prewarm() and prewarm_on_idle() downgrade them to warnings.
private$prewarm_context()
private$prewarm_sources()
invisible(self)
}
),
private = list(
prewarm_context = function() {
layer <- private$context_layer
layer_state <- if (is.null(layer)) NULL else context_layer_state(layer)
if (!is.null(layer_state) && length(layer_state$docs) > 0) {
local_commons_span(
"commons_context_prewarm",
attributes = list(
"commons.context.n_docs" = length(layer_state$docs),
"commons.context.cache_hit" = !is.null(layer_state$store)
# tryCatch: telemetry must not abort prewarming (resolving the
# cache dir can fail or warn on an unwritable root).
"commons.context.cache_hit" =
!is.null(layer_state$store) ||
isTRUE(tryCatch(
context_cache_enabled() &&
file.exists(context_store_path(layer_state$docs)),
error = function(err) FALSE
))
)
)
context_store(layer)
}
invisible(self)
},

prewarm_sources = function() {
for (source in private$sources) {
source_prewarm(source)
}
invisible(self)
}
),
private = list(
},

sources = NULL,
context_layer = NULL,
registry = NULL,
Expand Down
Loading
Loading