Rbebelm is intentionally two things at once:
The framework layer is deliberately small and interface-driven. The
loop owns agent lifecycle, queues, events, tool dispatch, extension
registration, and JSONL session persistence. Frontends such as a
console, RPC server, or the standalone tui/ Rust module
consume the same loop instead of reimplementing or owning agent
logic.
An LLM provider implements the BebelAgentBackend
interface from s7contract by providing S7 methods for these
generics:
bebel_backend_append_user(agent, message)bebel_backend_append_system(agent, message, tools = NULL)bebel_backend_append_tool_result(agent, content)bebel_backend_assistant_turn(agent, on_event, check_interrupt, stop_on_tool_call)bebel_backend_info(agent)bebel_backend_transcript(agent)bebel_backend_clear(agent)BebeLM implements this contract for BebelAgent. The
following fake backend is useful for tests and demonstrates that
bebel_agent_loop() does not require a BebeLM object.
library(Rbebelm)
FakeBebelAgentBackendS3 <- S7::new_S3_class("fakeBebelAgentBackendVignette")
S7::method(bebel_backend_append_user, FakeBebelAgentBackendS3) <- function(agent, message) {
agent$user <- c(agent$user, message)
agent
}
S7::method(bebel_backend_append_system, FakeBebelAgentBackendS3) <- function(agent, message, tools = NULL) {
agent$system <- c(agent$system, message)
agent
}
S7::method(bebel_backend_append_tool_result, FakeBebelAgentBackendS3) <- function(agent, content) {
agent$tool <- c(agent$tool, content)
agent
}
S7::method(bebel_backend_assistant_turn, FakeBebelAgentBackendS3) <- function(
agent,
on_event = NULL,
check_interrupt = TRUE,
stop_on_tool_call = FALSE
) {
if (!is.null(on_event)) on_event(list(type = "text_delta", delta = "fake reply"))
list(text = "fake reply", tokens = 2L, stop = "stop")
}
S7::method(bebel_backend_info, FakeBebelAgentBackendS3) <- function(agent) {
list(provider = "fake", model = "fake-model")
}
S7::method(bebel_backend_transcript, FakeBebelAgentBackendS3) <- function(agent) {
paste(c(agent$system, agent$user, agent$tool), collapse = "\n")
}
S7::method(bebel_backend_clear, FakeBebelAgentBackendS3) <- function(agent) {
agent$user <- character()
agent$tool <- character()
agent
}
backend <- structure(new.env(parent = emptyenv()), class = "fakeBebelAgentBackendVignette")
backend$user <- character()
backend$system <- character()
backend$tool <- character()bebel_agent_loop() is the UI-independent controller. It
accepts any BebelAgentBackend, optional tools, hooks,
extensions, and a persistence setting. The queue vocabulary follows
Pi:
bebel_loop_steer() adds steering messages.bebel_loop_follow_up() adds follow-up messages.bebel_loop_policy(steering_mode = ..., follow_up_mode = ...)
controls whether queued messages are drained one-at-a-time or all at
once.store_dir <- file.path(tempdir(), "rbebelm-framework-sessions")
store <- bebel_session_create(cwd = tempdir(), session_dir = store_dir, name = "fake backend")
loop <- bebel_agent_loop(backend, session = store)
run <- bebel_loop_run(loop, "Hello backend", max_steps = 1)
run$done
#> [1] TRUE
backend$user
#> [1] "Hello backend"
bebel_loop_state(loop)[c("state", "turns", "session_file")]
#> $state
#> [1] "idle"
#>
#> $turns
#> [1] 1
#>
#> $session_file
#> [1] "/tmp/RtmpKqcpr9/rbebelm-framework-sessions/2026-06-09T08-08-17-688Z_6e1a145e-bf7e-68d0-d47c-213e393f1773.jsonl"The loop writes generic message entries to the session store. The backend keeps its own transcript/cache state; the session file stores portable framework history for replay, browsing, forking, sharing, and UI state.
context <- bebel_session_context(store)
vapply(context$messages, `[[`, character(1), "role")
#> [1] "user" "assistant"
readLines(bebel_session_file(store), n = 3)
#> [1] "{\"type\":\"session\",\"version\":3,\"id\":\"6e1a145e-bf7e-68d0-d47c-213e393f1773\",\"timestamp\":\"2026-06-09T08:08:17.688Z\",\"cwd\":\"/tmp/RtmpKqcpr9\"}"
#> [2] "{\"type\":\"session_info\",\"id\":\"909aef6a\",\"parentId\":null,\"timestamp\":\"2026-06-09T08:08:17.690Z\",\"name\":\"fake backend\"}"
#> [3] "{\"type\":\"message\",\"id\":\"37150fd9\",\"parentId\":\"909aef6a\",\"timestamp\":\"2026-06-09T08:08:17.705Z\",\"message\":{\"role\":\"user\",\"content\":\"Hello backend\",\"source\":\"prompt\"}}"Sessions are inspired by Pi’s JSONL session format. The first line is
a session header; every other entry has an id,
parentId, timestamp, and type.
Entries form a tree, not only a linear log. Moving the leaf to an
earlier entry and appending creates a new branch without deleting the
old path.
Default persisted sessions live under:
Set RBEBELM_SESSION_DIR or pass session_dir
to override that location.
s <- bebel_session_create(cwd = tempdir(), session_dir = store_dir, name = "tree demo")
u1 <- bebel_session_append_message(s, "user", "first question")
a1 <- bebel_session_append_message(
s,
"assistant",
list(list(type = "text", text = "first answer")),
provider = "fake",
model = "fake-model",
stopReason = "stop"
)
bebel_session_checkout(s, u1)
u2 <- bebel_session_append_message(s, "user", "alternate branch")
vapply(bebel_session_branch(s), `[[`, character(1), "type")
#> [1] "session_info" "message" "message"
length(bebel_session_tree(s)[[1]]$children)
#> [1] 1Session entries include ordinary messages plus metadata and extension entries:
messagecustom for extension state that does
not enter model contextcustom_message for extension-injected contextlabelsession_infomodel_changethinking_level_changecompactionbranch_summarybebel_session_append_custom(s, "my-extension", list(counter = 1L))
#> [1] "dab41e28"
bebel_session_append_custom_message(s, "my-extension", "Hidden context", display = FALSE)
#> [1] "a6f1ca26"
bebel_session_append_label(s, u1, "checkpoint")
#> [1] "66eb54a1"
tail(vapply(bebel_session_entries(s), `[[`, character(1), "type"), 3)
#> [1] "custom" "custom_message" "label"Forking copies all non-header entries into a new session file with a new header. Cloning a branch copies only the active path from root to a selected leaf.
forked <- bebel_session_fork(bebel_session_file(s), cwd = tempdir(), session_dir = store_dir)
cloned <- bebel_session_clone_branch(s, leaf_id = u2, session_dir = store_dir)
bebel_session_header(forked)$parentSession
#> [1] "/tmp/RtmpKqcpr9/rbebelm-framework-sessions/2026-06-09T08-08-17-731Z_be99eb8c-efee-5adf-2415-38b8e06bf33e.jsonl"
vapply(bebel_session_entries(cloned), `[[`, character(1), "id")
#> [1] "68de3bdd" "914845c8" "e035700f"An extension is a backend-agnostic capability bundle registered into
the loop, not into a particular terminal UI. It should implement the
BebelAgentExtension interface:
bebel_extension_manifest(extension)bebel_extension_tools(extension)bebel_extension_commands(extension)bebel_extension_hooks(extension)bebel_extension_skill_providers(extension)bebel_extension_prompt_template_providers(extension)The helper bebel_extension() creates a simple extension
object implementing that interface. Extensions register into the loop;
they do not own the loop or a terminal frontend.
state_command <- bebel_loop_command(
"state",
function(args, loop, context) bebel_loop_state(loop),
description = "Return loop state."
)
ext <- bebel_extension(
"demo-extension",
commands = list(state = state_command),
hooks = list(event = function(event, loop, context, ...) {
context$last_event_type <- event$type
}),
metadata = list(ui = "frontends may render this")
)
bebel_extension_manifest(ext)
#> $name
#> [1] "demo-extension"
#>
#> $tools
#> NULL
#>
#> $commands
#> $commands$state
#> $commands$state$name
#> [1] "state"
#>
#> $commands$state$description
#> [1] "Return loop state."
#>
#> $commands$state$usage
#> [1] "/state"
#>
#>
#>
#> $hooks
#> [1] "event"
#>
#> $skill_providers
#> NULL
#>
#> $prompt_template_providers
#> NULL
#>
#> $keybindings
#> list()
#>
#> $widgets
#> list()
#>
#> $metadata
#> $metadata$ui
#> [1] "frontends may render this"When attached to a loop, contributed commands, tools, hooks, skill providers, and prompt-template providers are available through loop state and catalogs.
Skill providers and prompt-template providers are separate interfaces so system prompt loading is not tied to BebeLM. A provider can be in-memory, file-backed, or package-backed.
skills <- bebel_skill_provider(list(
concise = "Prefer concise, direct answers.",
r_safe = "Avoid side effects unless the user asks for them."
))
prompts <- bebel_prompt_template_provider(list(
system = "You are {{role}} working in {{place}}."
))
bebel_skill_list(skills)
#> name description path
#> 1 concise concise <NA>
#> 2 r_safe r_safe <NA>
bebel_prompt_template_list(prompts)
#> name description path
#> 1 system system <NA>
bebel_system_prompt(
prompts,
"system",
data = list(role = "an R coding agent", place = "Bamako"),
skill_provider = skills,
skills = c("concise", "r_safe")
)
#> [1] "You are an R coding agent working in Bamako.\n\n# Loaded skills\n\n## Skill: concise\n\nPrefer concise, direct answers.\n\n## Skill: r_safe\n\nAvoid side effects unless the user asks for them."bebel_append_system_prompt() renders a template, appends
selected skills, and then calls
bebel_backend_append_system(). This keeps system-prompt
loading generic; BebeLM-specific tool preamble details remain inside
BebeLM’s backend method.