A commons agent combines one or more data_source()
objects with a semantic_layer() of governed calculations.
For each question, the agent searches the semantic layer first. If a
measure matches, the agent runs a calculation defined by your data team.
Otherwise, it reads the data documentation, inspects the relevant
tables, and writes a SQL query.
A data source wraps a DBI connection. The agent queries the database directly, without copying its data.
The examples use a small in-process DuckDB database.
con <- DBI::dbConnect(duckdb::duckdb())
DBI::dbWriteTable(con, "orders", data.frame(
order_id = 1:6,
rep = c("Ada", "Ada", "Bo", "Cy", "Bo", "Ada"),
region = c("EMEA", "Americas", "EMEA", "APAC", "Americas", "EMEA"),
revenue = c(500, 900, 1200, 300, 2000, 750),
refunded = c(0, 100, 0, 0, 0, 50)
))
DBI::dbWriteTable(con, "reps", data.frame(
rep = c("Ada", "Bo", "Cy"),
hired = as.Date(c("2021-03-01", "2023-07-15", "2024-01-20"))
))By default, every table on the connection is listed in the system prompt:
In production, list only the relevant tables and open a read-only connection where the backend supports it:
tables also accepts schema-qualified names like
"analytics.orders" or DBI::Id() objects.
Table names rarely provide enough context. A data dictionary in the data-dict.yaml format describes what each table’s rows represent, what its columns mean, how tables join, and what your organization’s domain terms mean.
dictionary <- tempfile(fileext = ".yaml")
writeLines(
'
name: Sales
description: One row per closed order, plus the reps who closed them.
details: >
Revenue figures are gross. Net revenue subtracts the refunded column;
always report net revenue unless asked otherwise.
tables:
- name: orders
description: Closed orders, one row each.
columns:
- name: revenue
type: number
units: USD
description: Gross revenue for the order.
- name: refunded
type: number
units: USD
description: Amount refunded against the order.
- name: region
description: Sales region.
values: [EMEA, Americas, APAC]
- name: reps
description: One row per sales representative.
relationships:
- join: orders.rep = reps.rep
cardinality: many-to-one
description: Each order is credited to exactly one rep.
glossary:
net revenue: Gross revenue minus refunds.
',
dictionary
)
sales <- data_source(con, dictionary = dictionary)commons uses the dictionary in three places:
search_context searches the dictionary’s prose.A measure is an ordinary R function documented with roxygen comments
and marked with @measure. Documented arguments are supplied
by the model; undocumented arguments are hidden from it.
commons() supplies arguments omitted from
arguments, and the model does not see them. An argument
named after a data source receives that source’s connection, so the
measure need not depend on a variable defined elsewhere.
measure_file <- tempfile(fileext = ".R")
writeLines(
c(
"#' Net Revenue by Region",
"#'",
"#' @param region `enum[EMEA, Americas, APAC]` Sales region.",
"#' @measure",
"net_revenue_by_region <- function(region, warehouse) {",
" DBI::dbGetQuery(",
" warehouse,",
" 'SELECT sum(revenue - refunded) AS net_revenue FROM orders WHERE region = ?',",
" params = list(region)",
" )",
"}"
),
measure_file
)
layer <- semantic_layer(measure_file)
unlink(measure_file)The region enum limits the model to values present in
the data. warehouse does not appear in the model’s
schema.
semantic_layer() reads the documented measures from the
file into a layer.
commons() takes a chat client that supplies the provider
and model. It returns an ellmer::Chat with commons’ system
prompt and tools.
The data source name warehouse connects it to the
measure argument of the same name.
agent <- commons(
ellmer::chat_anthropic(),
data_sources = list(warehouse = sales),
semantic_layer = layer
)
agent$chat("What was net revenue in EMEA?")
#> Net revenue in EMEA was $2,400.The agent finds net_revenue_by_region with
search_measures("net revenue in EMEA"), then calls it with
region = "EMEA". The measure supplies the definition of net
revenue.
Without a matching measure, the agent can run SQL against the connection:
Here the agent searches the data documentation, describes the
reps table, and runs a SQL query.
Every commons agent has five tools:
| Tool | What it does |
|---|---|
search_measures |
Finds measures matching a question, with their argument schemas |
call_measure |
Runs a measure |
search_context |
Searches your data documentation |
describe_table |
Returns a table’s columns, types, and sample rows |
run_sql |
Runs a SQL query after checking its leading statement keyword |
run_sql checks the query’s leading statement keyword
against a denylist of common data- and schema-modifying operations
before passing it to the database. The check is a keyword filter;
database permissions remain the access-control boundary. Open a
read-only connection where possible, with access limited to the tables
the agent needs.
commons() returns an ellmer::Chat, so it
works with shinychat. Build a
fresh agent for each session so users do not share a conversation.
The default prompt is a markdown file shipped with commons. To customize it, copy the file into your project and interpolate the edited version:
file.copy(
system.file("prompts/system-prompt.md", package = "commons"),
"system-prompt.md"
)
commons(
ellmer::chat_anthropic(),
data_sources = list(warehouse = sales),
semantic_layer = layer,
system_prompt = ellmer::interpolate_file(
"system-prompt.md",
date = Sys.Date()
)
)commons appends the available tables and data dictionaries to the prompt. Omit them from the file.