feat(rlm): language-based workflows — Rhai .ragsh REPL as a first-class rlm tool (#4528)

This commit is contained in:
Steven Enamakel
2026-07-04 21:37:04 -07:00
committed by GitHub
parent 5650c6d33b
commit 269acb64d3
21 changed files with 2194 additions and 10 deletions
Generated
+99
View File
@@ -61,6 +61,20 @@ dependencies = [
"subtle",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"const-random",
"getrandom 0.3.4",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -1085,6 +1099,26 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "const_format"
version = "0.2.36"
@@ -3790,6 +3824,15 @@ dependencies = [
"libc",
]
[[package]]
name = "no-std-compat"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
dependencies = [
"spin",
]
[[package]]
name = "nom"
version = "7.1.3"
@@ -4255,6 +4298,9 @@ name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
dependencies = [
"portable-atomic",
]
[[package]]
name = "once_cell_polyfill"
@@ -5539,6 +5585,35 @@ dependencies = [
"subtle",
]
[[package]]
name = "rhai"
version = "1.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd4dd0f8c36625202a4ba553c416c19b719947cd2a31d1bda06126e4a5727daf"
dependencies = [
"ahash",
"bitflags 2.11.1",
"no-std-compat",
"num-traits",
"once_cell",
"rhai_codegen",
"smallvec",
"smartstring",
"thin-vec",
"web-time",
]
[[package]]
name = "rhai_codegen"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cd3a7535e50bf36857e7be7bec276d334e8c2dfa469c2201226fd01638ea5ca"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -6300,6 +6375,17 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smartstring"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
dependencies = [
"autocfg",
"static_assertions",
"version_check",
]
[[package]]
name = "socket2"
version = "0.6.3"
@@ -6360,6 +6446,12 @@ dependencies = [
"socketioxide-core",
]
[[package]]
name = "spin"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
[[package]]
name = "spki"
version = "0.7.3"
@@ -6605,6 +6697,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "thin-vec"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482"
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -6715,6 +6813,7 @@ dependencies = [
"async-trait",
"futures",
"reqwest 0.12.28",
"rhai",
"rusqlite",
"serde",
"serde_json",
+3 -1
View File
@@ -61,7 +61,9 @@ tinyjuice = { version = "0.1", default-features = false }
# aligned to 0.40, avoiding duplicate `links = "sqlite3"` native bindings.
# Durable graph checkpoints still use `SqlRunLedgerCheckpointer` until the
# migration re-points those rows to the crate checkpointer.
tinyagents = { version = "1.5.0", features = ["sqlite"] }
# The `repl` feature adds the embedded Rhai `.ragsh` session runtime powering
# the `rlm` language-workflow tool (`src/openhuman/rlm/`).
tinyagents = { version = "1.5.0", features = ["sqlite", "repl"] }
# TinyCortex — Rust core for the memory engine (store/chunks/tree/retrieval/
# queue/ingest/score + long tail), vendored as a git submodule and patched
# below to `vendor/tinycortex`. OpenHuman's memory subsystem migrates onto this
@@ -49,12 +49,12 @@ icon: layer-group
## TinyAgents crate: features & compatibility
OpenHuman pins `tinyagents = { version = "1.5.0", features = ["sqlite"] }` (see [`Cargo.toml`](../../../Cargo.toml)). The rationale, so future upgrades don't silently regress it:
OpenHuman pins `tinyagents = { version = "1.5.0", features = ["sqlite", "repl"] }` (see [`Cargo.toml`](../../../Cargo.toml)). The rationale, so future upgrades don't silently regress it:
- **OpenHuman-owned providers only.** We do **not** enable any bundled provider feature. OpenHuman owns provider transport, credentials, OAuth, and billing classification, so the live model is always OpenHuman's `Provider` wrapped as [`ProviderModel`](../../../src/openhuman/tinyagents/model.rs), never an SDK-owned provider client. The `ChatModel` adapter is the seam that replaces feature-gated SDK providers.
- **`sqlite` feature enabled with one native sqlite chain.** OpenHuman's root and Tauri Cargo worlds pin `rusqlite = "=0.40.0"` and patch `rusqlite` / `libsqlite3-sys` locally to avoid the upstream `cfg_select!` build break on the current toolchain. Both worlds resolve to a single `libsqlite3-sys v0.38.0` chain. Durable graph checkpoints still run through [`SqlRunLedgerCheckpointer`](../../../src/openhuman/tinyagents/checkpoint.rs) until the migration re-points those rows to the crate checkpointer.
- **WhatsApp Web storage bridge.** `whatsapp-rust`'s Diesel-backed `sqlite-storage` feature links sqlite separately from rusqlite 0.40, so the optional `whatsapp-web` feature currently builds against `wacore::store::InMemoryBackend` and logs that sessions are not durable. A rusqlite-backed durable WhatsApp store is required before treating Web sessions as persistent again.
- **`repl` / expressive-language features unused.** OpenHuman drives graphs from Rust (`GraphBuilder`), not the crate's `.rag` REPL language.
- **`repl` feature enabled for language workflows; `.rag` expressive language unused.** OpenHuman still drives *graphs* from Rust (`GraphBuilder`), not the declarative `.rag` language. But the `repl` feature (the imperative Rhai `.ragsh` session runtime) is enabled to power the `rlm` language-workflow tool ([`openhuman::rlm`](../../../src/openhuman/rlm/README.md), see "Language workflows (`rlm`)" below).
- **Adapter map (feature-gated SDK piece → OpenHuman replacement):** provider clients → `ProviderModel`; crate SQLite checkpointer rows not yet adopted → `SqlRunLedgerCheckpointer`; task/status stores not yet controller-canonical → OpenHuman SQL/JSON run ledgers (`running_subagents`, `workflow_runs`, `agent_teams`, `command_center`). The generic harness/graph/middleware/event primitives are used as-is.
The agent harness is the runtime that turns a user message (or a webhook fire, or a cron tick) into a complete, tool-using LLM interaction. It owns the tool-call loop, sub-agent dispatch, the trigger-triage pipeline, and the hook surface around them. It does **not** own provider HTTP transport, tool implementations, prompt-section assembly, or memory storage - those are separate domains the harness composes.
@@ -282,6 +282,21 @@ Each `AgentDefinition` carries an `agent_tier` field (`chat` / `reasoning` / `wo
For Composio toolkits with hundreds of actions (GitHub alone has 500+), loading every action into the sub-agent's tool set balloons prompt size. The harness ranks the toolkit's actions against the parent-refined task prompt with a cheap CPU-only filter (verb detection, token overlap, verb-alignment boost) and only loads the top-ranked subset into the sub-agent. No model call, pure heuristic - fast and explainable.
## Language workflows (`rlm`)
The fixed delegation primitives (`spawn_subagent`, `spawn_parallel_agents`, `run_workflow`) can't express *ad-hoc control flow* — "spawn N readers, dedupe their findings, verify each survivor with 3 refuters, loop until dry". The **`rlm` tool** closes that gap: it exposes TinyAgents' Rhai-backed `.ragsh` REPL (the `repl` cargo feature) so the orchestrator can author and run its own workflow scripts.
**One tool call = one `eval_cell`.** The orchestrator's normal tool-call loop *is* the CodeAct driver loop: the model writes a Rhai cell, the cell runs against a persistent per-session namespace (top-level `let` bindings survive into the next cell via an optional `session_id`), and the structured result flows back as the tool result. Scripts reach the host only through capability functions — `tool_call`, `agent_query`, `model_query`, their `*_batched` fan-out variants, `emit`, and `answer`.
The domain lives in [`src/openhuman/rlm/`](../../../src/openhuman/rlm/README.md):
- **`policy.rs`** maps the autonomy tier + `tool_timeout` clamps onto a `tinyagents::ReplPolicy` (always bounded, never unbounded; `readonly` refused; `full` may raise call-count limits to a hard 2× ceiling).
- **`bridge.rs`** builds the `CapabilityRegistry`: the parent's visible tools (each re-wrapped so the **approval gate runs in the bridge** — it is *not* on the repl path, which bypasses the harness `wrap_tool` middleware), the turn's provider model, and a sub-agent capability per `allowed_subagent_ids`. Recursion/duplication hazards (`rlm`, `spawn_*`, workflow tools, `CliRpcOnly`-scoped tools) are excluded. Because `eval_cell` runs on `spawn_blocking` + `block_on`, the `agent_query` adapter re-installs the `PARENT_CONTEXT` task-local that `run_subagent` resolves.
- **`sessions.rs`** is a bounded (LRU + idle-TTL) manager of persistent sessions, one cell at a time (a concurrent call on a busy session returns a typed "busy" error).
- **`ops.rs`** runs the cell on `spawn_blocking` under a layered time bound (rhai `on_progress` deadline → `bridge_block_on` timer race → outer `tokio::timeout` backstop → harness `ToolTimeout`), wires the run-cancellation token to a fresh per-cell `ReplCancelFlag`, and maps every failure mode to a model-consumable result.
The tool is registered for the orchestrator on `supervised`/`full` tiers only, behind the `OPENHUMAN_RLM=0` kill switch. The TinyAgents-side host-embedding support (external cancellation, live capability events) landed in that crate's `repl` feature.
## Triage - handling external triggers
When a webhook fires, a cron ticks, or a Composio event arrives, the system can't just hand it straight to the orchestrator. Most triggers are noise; some warrant a notification; only a few deserve a full agent turn. The **trigger-triage pipeline** is the gate.
+10
View File
@@ -620,6 +620,16 @@ pub(super) const CAPABILITIES: &[Capability] = &[
status: CapabilityStatus::Beta,
privacy: DERIVED_TO_BACKEND,
},
Capability {
id: "intelligence.language_workflows",
name: "Language Workflows (RLM)",
domain: "intelligence",
category: CapabilityCategory::Intelligence,
description: "The orchestrator can author and run small Rhai workflow scripts to express ad-hoc control flow over delegated work — parallel fan-out, loops, and dedup-then-verify pipelines that fixed spawn/parallel primitives cannot. Each script runs bounded and fail-closed (per-cell timeout, per-session caps on tool/model/agent calls and recursion depth), and every effectful step still passes the same approval and permission gates as a direct tool call. Progress rides the existing tool-call timeline.",
how_to: "Runs automatically when the orchestrator chooses the `rlm` tool; disable with OPENHUMAN_RLM=0 or the read-only autonomy tier",
status: CapabilityStatus::Beta,
privacy: DERIVED_TO_BACKEND,
},
Capability {
id: "intelligence.agent_library",
name: "Agents Library",
@@ -936,7 +936,8 @@ mod tests {
#[test]
fn build_worker_thread_title_collapses_whitespace_and_caps_length() {
let prompt = " draft\n a very long\tplan that\nrambles ".to_string() + &"x".repeat(200);
let prompt =
" draft\n a very long\tplan that\nrambles ".to_string() + "x".repeat(200).as_str();
let title = build_worker_thread_title(&prompt);
assert!(title.starts_with("draft a very long plan"));
assert!(title.chars().count() <= WORKER_THREAD_TITLE_MAX_CHARS + 1);
@@ -103,6 +103,28 @@ their refs separate by `subagent_session_id` or `task_id` (`agentId` is only the
worker type), tick or wait on each independently, and synthesise only completed
outputs. Never fabricate a result for a worker that is still running or failed.
## Language workflows (rlm)
When a task needs **ad-hoc control flow** over delegated work — loops, conditionals, a
dedup-then-verify pipeline, "spawn N, filter, then verify each survivor with M checks" — that
the fixed `spawn_parallel_agents` / `delegate_*` primitives can't express, use the `rlm` tool.
It evaluates a small **Rhai workflow cell** whose only side effects are capability calls:
`tool_call`, `agent_query`, `model_query`, and their `*_batched` fan-out variants (plus
`emit`/`answer`/`print`).
- **One call = one cell.** Top-level `let` bindings persist within a `session_id`, so pass the
same `session_id` back to continue a namespace across calls (`let findings = …` in cell 1,
reference it in cell 2). Omit `session_id` for a fresh session; set `close_session: true` when done.
- **Prefer `rlm`** over `spawn_parallel_agents` when you need iteration, branching, or a
reduce/verify step over results — not for a single delegation (use the matching `delegate_*`
or `spawn_subagent` for that).
- **It stays inside the gates.** Every effectful inner `tool_call` still hits the approval gate;
`agent_query` only reaches sub-agents already in your allowlist. `rlm` itself, `spawn_*`, and
workflow tools are not callable from a cell.
- **It is bounded and fail-closed.** Cells have a wall-clock timeout and per-session caps on
model/tool/agent calls and recursion depth. Exceeding one returns an error you can fix and
retry in the same session; the result reports `limits_remaining` so you can plan fan-out.
## Connecting external services
When the user asks to connect a service (Gmail, Notion, WhatsApp, Calendar, Drive, etc.) or a sub-agent reports `Connection error, try to authenticate`:
+1
View File
@@ -98,6 +98,7 @@ pub mod provider_surfaces;
pub mod recall_calendar;
pub mod redirect_links;
pub mod referral;
pub mod rlm;
pub mod routing;
pub mod runtime_node;
pub mod runtime_python;
+70
View File
@@ -0,0 +1,70 @@
# `rlm` — language-based workflows (Rhai `.ragsh` REPL)
Exposes TinyAgents' Rhai-backed `.ragsh` session runtime (the `repl` cargo
feature, `tinyagents::ReplSession`) as a first-class **`rlm` tool** so the
orchestrator model can author and execute its own workflow scripts — fan-out
over subagents, batched tool/model calls, loops, dedup/verify pipelines — and
run them deterministically, in the spirit of Claude Code Workflows and
Recursive Language Models.
One orchestrator tool call maps to **one `eval_cell`**: the model writes a Rhai
cell, the cell runs against a persistent per-session namespace (top-level `let`
bindings survive into the next cell), and the structured result flows back as
the tool result. The orchestrator's own turn loop *is* the CodeAct driver loop.
## Module shape
| File | Role |
| ---- | ---- |
| `mod.rs` | Exports only (no controller schemas in v1). |
| `types.rs` | `RlmSessionId`, `RlmEvalRequest`/`RlmEvalResponse`, `RlmLimitsOverride`, `RlmCallSummary`, serde types. |
| `policy.rs` | Maps openhuman autonomy tier + `tool_timeout` clamps → `tinyagents::ReplPolicy` (fail-closed, bounded). |
| `bridge.rs` | Builds the `CapabilityRegistry<()>`: openhuman tools (approval-gated, scope-filtered) + provider models + subagents. |
| `sessions.rs` | `RlmSessionManager`: LRU + idle-TTL bounded map of persistent `ReplSession`s, keyed `<thread>:<session_id>`, one cell at a time. |
| `ops.rs` | `eval_rlm_cell`: spawn_blocking + outer timeout, cancellation wiring, event forwarding, error → model-consumable result. |
| `tools.rs` | `RlmTool` (the `rlm` tool: schema, permission, scope, timeout, display). |
## Fail-closed guarantees
Every failure mode returns a **model-consumable tool result** — never a panic,
never a hung turn:
- **Layered time bounds:** (1) rhai `on_progress` deadline for pure script
loops; (2) `bridge_block_on` timer race for hung capability futures;
(3) an outer `tokio::time::timeout(policy.timeout + 5s)` around
`spawn_blocking`; (4) the harness `ToolTimeout::Secs` backstop above all of
them. The tool's own timeout is always set below the harness backstop.
- **Bounded sessions:** LRU cap (16) + idle TTL (30 min); a second concurrent
call on a busy session returns a typed "session busy" error rather than
deadlocking; a poisoned/errored session is dropped, never reused.
- **Bounded work per session:** `ReplPolicy` caps on operations, output bytes,
script bytes, and per-kind call counts. `full` tier may raise call-count
limits up to a hard 2× ceiling via the tool's `limits` arg; `readonly` tier
does not get the tool at all.
- **Cancellation end-to-end:** the turn's run-cancellation token drives a
`ReplCancelFlag` watcher, so a user cancel drops an in-flight cell (script or
capability call) promptly; the session is left resumable.
## Approval & security (bridged tools keep their own gates)
The RLM bridge restricts callable tools to the parent turn's
`visible_tool_names`, and **excludes** recursion/duplication hazards: `rlm`
itself, `spawn_subagent`/`spawn_parallel_agents` (use `agent_query` instead),
and `run_workflow`/`await_workflow`. `ToolScope::CliRpcOnly` tools are denied.
Approval gating is **not** on the tinyagents repl bridge path (it lives in the
harness `wrap_tool` middleware, which the REPL bypasses), so the bridge itself
invokes `ApprovalGate::intercept_audited` (+ `record_execution`) for any tool
whose `external_effect_with_args` is true, failing closed on denial.
## Capability surface exposed to scripts
`model_query`, `tool_call`, `agent_query`, their `*_batched` variants, `emit`,
and `answer`. Graph authoring/execution (`graph_*`) is **out of scope for v1**
(the REPL's `graph_run` returns a reference, not an execution).
## Kill switch & rollout
The tool is **not registered** when the autonomy tier is `readonly` or when
`OPENHUMAN_RLM=0`; default-on for `supervised`/`full`. Reverting the
registration line disables the surface without touching the domain.
+326
View File
@@ -0,0 +1,326 @@
//! Capability bridge: projects openhuman's tools, provider model, and
//! sub-agents into a `tinyagents` [`CapabilityRegistry`] a `.ragsh` session
//! binds against.
//!
//! Three capability kinds are wired, each keeping openhuman's own gates:
//!
//! - **Tools** — one [`RlmToolAdapter`] per visible, non-excluded, agent-scoped
//! tool. Approval is **not** on the tinyagents repl path (it lives in the
//! harness `wrap_tool` middleware the REPL bypasses), so the adapter itself
//! invokes the [`ApprovalGate`] for any tool whose `external_effect_with_args`
//! is true, failing closed on denial and recording the terminal outcome.
//! - **Model** — the turn's provider, registered under its model name so
//! `model_query(#{model: "<name>"})` hits the real backend with usage intact.
//! - **Agents** — a [`SubagentCapability`] per entry in the parent's
//! `allowed_subagent_ids`, so `agent_query("<id>", ...)` spawns a real
//! openhuman sub-agent through `run_subagent`.
//!
//! Recursion/duplication hazards are **excluded** from the tool surface: `rlm`
//! itself (no REPL-in-REPL), `spawn_*` (use `agent_query`), and
//! `run_workflow`/`await_workflow`. `ToolScope::CliRpcOnly` tools are excluded
//! too.
use std::sync::Arc;
use async_trait::async_trait;
use tinyagents::graph::subagent_node::{HarnessAgent, SubAgentInput, SubAgentOutput};
use tinyagents::harness::events::EventSink;
use tinyagents::harness::tool::{
Tool as TaTool, ToolCall as TaToolCall, ToolExecutionContext, ToolPolicy as TaToolPolicy,
ToolResult as TaToolResult, ToolSchema as TaToolSchema,
};
use tinyagents::registry::CapabilityRegistry;
use tinyagents::TinyAgentsError;
use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry;
use crate::openhuman::agent::harness::fork_context::{with_parent_context, ParentExecutionContext};
use crate::openhuman::agent::harness::subagent_runner::{run_subagent, SubagentRunOptions};
use crate::openhuman::approval::{
redact_args, summarize_action, ApprovalGate, ExecutionOutcome, GateOutcome,
};
use crate::openhuman::tinyagents::model::provider_chat_model;
use crate::openhuman::tinyagents::tools::{
execute_openhuman_tool, tool_policy_from_openhuman_tool,
};
use crate::openhuman::tools::traits::ToolScope;
use crate::openhuman::tools::Tool as OhTool;
/// Tools never exposed to a `.ragsh` script, to prevent recursion (a script
/// re-entering the REPL) and capability duplication (spawn/workflow primitives
/// the script models with `agent_query` instead).
fn is_excluded_tool(name: &str) -> bool {
name == "rlm"
|| name == "run_workflow"
|| name == "await_workflow"
|| name.starts_with("spawn_")
}
#[cfg(test)]
mod tests {
use super::is_excluded_tool;
#[test]
fn recursion_and_duplication_hazards_are_excluded() {
for name in [
"rlm",
"run_workflow",
"await_workflow",
"spawn_subagent",
"spawn_parallel_agents",
"spawn_async_subagent",
] {
assert!(is_excluded_tool(name), "{name} should be excluded");
}
for name in ["read_file", "grep", "edit_file", "web_search"] {
assert!(!is_excluded_tool(name), "{name} should be callable");
}
}
}
/// Builds the `CapabilityRegistry<()>` a session binds against from the parent
/// turn's execution context.
///
/// Reads the parent's visible tool set, provider/model, and sub-agent
/// allowlist. The returned registry carries no `rlm`, `spawn_*`, or workflow
/// tools, and no `CliRpcOnly`-scoped tools.
pub(super) fn build_capability_registry(parent: &ParentExecutionContext) -> CapabilityRegistry<()> {
let mut registry = CapabilityRegistry::<()>::new();
// ── Model: the turn's provider, under its registered name. ──
let model = provider_chat_model(
parent.provider.clone(),
parent.model_name.clone(),
parent.temperature,
);
registry.replace_model(parent.model_name.clone(), model);
// ── Tools: visible, non-excluded, agent-scoped only. ──
let mut tool_count = 0usize;
for tool in parent.all_tools.iter() {
let name = tool.name();
if !parent.visible_tool_names.is_empty() && !parent.visible_tool_names.contains(name) {
continue;
}
if is_excluded_tool(name) {
continue;
}
if matches!(tool.scope(), ToolScope::CliRpcOnly) {
continue;
}
registry.replace_tool(Arc::new(RlmToolAdapter::new(
parent.all_tools.clone(),
tool.as_ref(),
)));
tool_count += 1;
}
// ── Agents: one capability per allowed sub-agent id. ──
let mut agent_count = 0usize;
for agent_id in &parent.allowed_subagent_ids {
registry.replace_agent(Arc::new(SubagentCapability {
agent_id: agent_id.clone(),
parent: parent.clone(),
}));
agent_count += 1;
}
tracing::debug!(
tools = tool_count,
agents = agent_count,
model = %parent.model_name,
"[rlm] built capability registry"
);
registry
}
/// A `tinyagents` tool backed by an openhuman [`Tool`](OhTool), located by name
/// in the parent's shared tool set on each call (the set is `Arc`-shared, not
/// cloned). Adds the approval gate the harness `wrap_tool` middleware would
/// otherwise apply — absent on the repl bridge path.
pub(super) struct RlmToolAdapter {
tools: Arc<Vec<Box<dyn OhTool>>>,
name: String,
description: String,
schema: TaToolSchema,
policy: TaToolPolicy,
}
impl RlmToolAdapter {
fn new(tools: Arc<Vec<Box<dyn OhTool>>>, tool: &dyn OhTool) -> Self {
let schema = TaToolSchema {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
format: Default::default(),
};
Self {
name: tool.name().to_string(),
description: tool.description().to_string(),
schema,
policy: tool_policy_from_openhuman_tool(tool),
tools,
}
}
async fn dispatch(
&self,
call: TaToolCall,
context: Option<&ToolExecutionContext>,
) -> TaToolResult {
let found = self.tools.iter().find(|t| t.name() == self.name);
match found {
Some(tool) => gated_execute(tool.as_ref(), call, context).await,
None => {
tracing::warn!(tool = %self.name, "[rlm] bridged tool not found at call time");
TaToolResult {
call_id: call.id,
name: call.name,
content: format!("Error: unknown tool '{}'", self.name),
raw: None,
error: Some("unknown tool".to_string()),
elapsed_ms: 0,
}
}
}
}
}
#[async_trait]
impl TaTool<()> for RlmToolAdapter {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn schema(&self) -> TaToolSchema {
self.schema.clone()
}
fn policy(&self) -> TaToolPolicy {
self.policy.clone()
}
async fn call(&self, _state: &(), call: TaToolCall) -> tinyagents::Result<TaToolResult> {
Ok(self.dispatch(call, None).await)
}
async fn call_with_context(
&self,
_state: &(),
call: TaToolCall,
context: ToolExecutionContext,
) -> tinyagents::Result<TaToolResult> {
Ok(self.dispatch(call, Some(&context)).await)
}
}
/// Runs an openhuman tool for a `.ragsh` `tool_call`, routing any external-effect
/// tool through the [`ApprovalGate`] first (fail-closed on denial) since the
/// repl bridge sits outside the harness approval middleware.
async fn gated_execute(
tool: &dyn OhTool,
call: TaToolCall,
context: Option<&ToolExecutionContext>,
) -> TaToolResult {
if tool.external_effect_with_args(&call.arguments) {
if let Some(gate) = ApprovalGate::try_global() {
let summary = summarize_action(&call.name, &call.arguments);
let redacted = redact_args(&call.arguments);
tracing::debug!(tool = %call.name, "[rlm] external-effect tool — routing through approval gate");
let (outcome, request_id) =
gate.intercept_audited(&call.name, &summary, redacted).await;
match outcome {
GateOutcome::Deny { reason } => {
tracing::info!(tool = %call.name, %reason, "[rlm] tool denied by approval gate");
return TaToolResult {
content: format!("Denied by approval gate: {reason}"),
error: Some(format!("approval denied: {reason}")),
raw: None,
elapsed_ms: 0,
call_id: call.id,
name: call.name,
};
}
GateOutcome::Allow => {
let result = execute_openhuman_tool(tool, call, context).await;
if let Some(id) = request_id {
let terminal = if result.error.is_none() {
ExecutionOutcome::Success
} else {
ExecutionOutcome::Failure
};
gate.record_execution(&id, terminal, result.error.as_deref());
}
return result;
}
}
}
// No global gate installed (e.g. gate disabled): fall through and
// execute — the harness-level env kill-switch owns that decision.
}
execute_openhuman_tool(tool, call, context).await
}
/// A `.ragsh` `agent_query("<id>", ...)` capability that spawns a real openhuman
/// sub-agent via `run_subagent`.
///
/// Captures the parent [`ParentExecutionContext`] at bridge-build time and
/// **re-installs it** with [`with_parent_context`] before calling
/// `run_subagent`: the session's `eval_cell` runs on `spawn_blocking` +
/// `futures::executor::block_on`, which does not carry the `PARENT_CONTEXT`
/// task-local `run_subagent` resolves — without this the spawn would fail with
/// `NoParentContext`.
struct SubagentCapability {
agent_id: String,
parent: ParentExecutionContext,
}
#[async_trait]
impl HarnessAgent for SubagentCapability {
fn name(&self) -> &str {
&self.agent_id
}
async fn run(
&self,
input: SubAgentInput,
_events: EventSink,
) -> tinyagents::Result<SubAgentOutput> {
let registry = AgentDefinitionRegistry::global().ok_or_else(|| {
TinyAgentsError::Capability("agent registry not initialised".to_string())
})?;
let definition = registry.get(&self.agent_id).ok_or_else(|| {
TinyAgentsError::Capability(format!("agent `{}` is not registered", self.agent_id))
})?;
// Defensive re-check of the parent allowlist (the bridge only registers
// allowed agents, but never trust that a script cannot reach further).
if !self.parent.allowed_subagent_ids.contains(&definition.id) {
return Err(TinyAgentsError::Capability(format!(
"agent `{}` is not in the parent's subagent allowlist",
definition.id
)));
}
let options = SubagentRunOptions {
workspace_descriptor: self.parent.workspace_descriptor.clone(),
..Default::default()
};
tracing::debug!(agent = %self.agent_id, "[rlm] agent_query — spawning sub-agent");
let outcome = with_parent_context(
self.parent.clone(),
run_subagent(definition, &input.prompt, options),
)
.await
.map_err(|e| TinyAgentsError::Tool(format!("sub-agent `{}` failed: {e}", self.agent_id)))?;
Ok(SubAgentOutput {
text: outcome.output,
model_calls: outcome.iterations,
..Default::default()
})
}
}
+20
View File
@@ -0,0 +1,20 @@
//! `rlm` — language-based workflows over the TinyAgents Rhai `.ragsh` REPL.
//!
//! Exposes `tinyagents::ReplSession` (the `repl` feature) as a first-class
//! [`RlmTool`], so the orchestrator can author and run its own workflow scripts
//! — fan-out, batched calls, loops, dedup/verify pipelines — bounded and
//! fail-closed. See [`README.md`](https://github.com/tinyhumansai/openhuman)
//! (module `README.md`) for the design.
//!
//! Module shape (canonical): `mod.rs` exports only; logic lives in the
//! siblings. No controller schemas in v1 (`scope() = AgentOnly`).
mod bridge;
mod ops;
mod policy;
mod sessions;
mod types;
pub mod tools;
pub use tools::RlmTool;
+675
View File
@@ -0,0 +1,675 @@
//! Cell evaluation orchestration: resolve the session, run the cell off the
//! async workers (`spawn_blocking`) under layered time bounds, forward
//! cancellation, and map every failure mode to a model-consumable result.
use std::sync::{Arc, TryLockError};
use std::time::Duration;
use tinyagents::registry::{CapabilityRegistry, ComponentKind};
use tinyagents::{
ReplCallKind, ReplCallRecord, ReplCancelFlag, ReplCapabilities, ReplResult, ReplSession,
TinyAgentsError,
};
use crate::openhuman::agent::harness::fork_context::current_parent;
use crate::openhuman::security::live_policy;
use crate::openhuman::security::policy::AutonomyLevel;
use crate::openhuman::tinyagents::run_cancellation_context::current_run_cancellation;
use super::bridge::build_capability_registry;
use super::policy::{resolve_policy, DEFAULT_RLM_TIMEOUT_SECS};
use super::sessions::RlmSessionManager;
use super::types::{RlmCallSummary, RlmEvalRequest, RlmEvalResponse, RlmLimitsRemaining};
/// Grace added to the inner policy timeout for the outer `spawn_blocking`
/// backstop — the inner deadline should always fire first; this defends against
/// bugs in the inner layers, and if it ever fires the session is dropped.
const OUTER_TIMEOUT_GRACE: Duration = Duration::from_secs(5);
/// Hard cap on the characters returned to the model in `stdout`, so a runaway
/// print cannot flood the context window even within the byte policy.
const MAX_RLM_RESULT_CHARS: usize = 24_000;
/// A typed RLM failure, each mapped to a model-consumable tool result by
/// [`RlmError::message`] (with a fix hint) and [`RlmError::kind`].
#[derive(Debug, Clone)]
pub(crate) enum RlmError {
/// The autonomy tier or policy refused the session.
Denied(String),
/// Script failed to compile or raised a runtime error.
Script(String),
/// A wall-clock deadline elapsed (script or in-flight capability call).
Timeout(String),
/// An op/output/script-size/call-count limit tripped.
LimitExceeded(String),
/// A script referenced an unknown tool/model/agent.
UnknownCapability(String),
/// Sub-agent recursion depth exceeded.
Depth(String),
/// A capability call itself returned an error.
CapabilityError(String),
/// The user cancelled the run.
Cancelled,
/// The session is already evaluating another cell.
SessionBusy(String),
/// An internal failure (join panic, poisoned session, missing context).
Internal(String),
}
impl RlmError {
/// A stable, snake_case kind tag for logging and the tool result.
pub(crate) fn kind(&self) -> &'static str {
match self {
RlmError::Denied(_) => "denied",
RlmError::Script(_) => "script_error",
RlmError::Timeout(_) => "timeout",
RlmError::LimitExceeded(_) => "limit_exceeded",
RlmError::UnknownCapability(_) => "unknown_capability",
RlmError::Depth(_) => "recursion_depth",
RlmError::CapabilityError(_) => "capability_error",
RlmError::Cancelled => "cancelled",
RlmError::SessionBusy(_) => "session_busy",
RlmError::Internal(_) => "internal",
}
}
/// The model-facing message, including a concrete fix hint.
pub(crate) fn message(&self) -> String {
match self {
RlmError::Denied(m) => m.clone(),
RlmError::Script(m) => {
format!("rlm script error: {m}\nFix the script and retry — reuse the same session_id to keep your bindings.")
}
RlmError::Timeout(m) => {
format!("rlm cell timed out: {m}\nSplit the work across cells, lower fan-out, or raise timeout_secs.")
}
RlmError::LimitExceeded(m) => {
format!("rlm limit exceeded: {m}\nSplit the work across cells, or (full tier) raise the relevant limit in `limits`.")
}
RlmError::UnknownCapability(m) => format!("rlm unknown capability: {m}"),
RlmError::Depth(m) => format!("rlm recursion depth exceeded: {m}"),
RlmError::CapabilityError(m) => {
format!("rlm capability call failed: {m}\nInspect the failing call's arguments and retry.")
}
RlmError::Cancelled => {
"rlm cell was cancelled by the user. The session is intact and resumable."
.to_string()
}
RlmError::SessionBusy(m) => m.clone(),
RlmError::Internal(m) => format!("rlm internal error: {m}"),
}
}
}
/// The registered capability names, snapshotted for unknown-capability error
/// messages so the model sees the live surface it can actually call.
struct RlmAvailable {
tools: Vec<String>,
agents: Vec<String>,
models: Vec<String>,
}
impl RlmAvailable {
fn from_registry(registry: &CapabilityRegistry<()>) -> Self {
Self {
tools: registry.names(ComponentKind::Tool),
agents: registry.names(ComponentKind::Agent),
models: registry.names(ComponentKind::Model),
}
}
}
/// The internal result of the blocking cell task, distinguishing a lock
/// contention / poison from an actual evaluation outcome.
enum CellRun {
/// The cell ran; carries the raw evaluation result.
Completed(Result<ReplResult, TinyAgentsError>),
/// Another cell holds the session lock.
Busy,
/// The session mutex was poisoned by a prior panic.
Poisoned,
}
/// Evaluates one RLM cell against a (possibly new) session, fail-closed.
///
/// Resolves the parent turn context, maps autonomy/timeout to a `ReplPolicy`,
/// resolves or creates the session, wires user cancellation to a fresh per-cell
/// flag, runs `eval_cell` on `spawn_blocking` under an outer `tokio::timeout`
/// backstop, and maps every outcome to [`RlmEvalResponse`] or a typed
/// [`RlmError`].
pub(crate) async fn eval_rlm_cell(req: RlmEvalRequest) -> Result<RlmEvalResponse, RlmError> {
let parent = current_parent().ok_or_else(|| {
RlmError::Internal(
"no parent execution context — the rlm tool must run inside an agent turn".to_string(),
)
})?;
let tier = live_policy::current()
.map(|p| p.autonomy)
.unwrap_or(AutonomyLevel::Supervised);
let policy =
resolve_policy(tier, req.timeout_secs, req.limits.as_ref()).map_err(RlmError::Denied)?;
let registry = build_capability_registry(&parent);
run_cell(
req,
policy,
registry,
&parent.session_id,
RlmSessionManager::global(),
)
.await
}
/// The parent-independent core of [`eval_rlm_cell`]: given a resolved policy and
/// a prebuilt capability registry, resolves/creates the session, runs the cell
/// under the layered time bounds and cancellation wiring, and maps the outcome.
///
/// Split out so the full evaluation path is unit-testable with a hand-built
/// registry, without constructing a whole `ParentExecutionContext`.
async fn run_cell(
req: RlmEvalRequest,
policy: tinyagents::ReplPolicy,
registry: CapabilityRegistry<()>,
thread_scope: &str,
manager: &RlmSessionManager,
) -> Result<RlmEvalResponse, RlmError> {
let session_id = req
.session_id
.clone()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| format!("rlm-{}", uuid::Uuid::new_v4()));
let key = RlmSessionManager::session_key(thread_scope, &session_id);
// Snapshot the registry's names for error messages, then hand it to the
// session builder (used only on a fresh session; a reused session keeps its
// own registry).
let available = RlmAvailable::from_registry(&registry);
let policy_for_build = policy.clone();
tracing::info!(
session_key = %key,
thread_id = %thread_scope,
session_id = %session_id,
script_bytes = req.script.len(),
"[rlm] eval_rlm_cell: start"
);
let handle = manager.get_or_create(&key, move || {
let capabilities = ReplCapabilities::new(Arc::new(registry));
ReplSession::<()>::new()
.with_capabilities(capabilities)
.with_policy(policy_for_build)
});
// Fresh per-cell cancel flag (sticky flags would leave a reused session
// permanently cancelled), wired to the turn's run-cancellation token.
let cell_flag = ReplCancelFlag::new();
if let Some(token) = current_run_cancellation() {
let flag = cell_flag.clone();
tokio::spawn(async move {
token.cancelled().await;
tracing::debug!("[rlm] run cancellation observed — tripping cell cancel flag");
flag.cancel();
});
}
let session = handle.session.clone();
let script = req.script.clone();
let flag_for_cell = cell_flag.clone();
let outer_bound = policy
.timeout
.unwrap_or(Duration::from_secs(DEFAULT_RLM_TIMEOUT_SECS))
+ OUTER_TIMEOUT_GRACE;
let join = tokio::task::spawn_blocking(move || {
let mut guard = match session.try_lock() {
Ok(guard) => guard,
Err(TryLockError::WouldBlock) => return CellRun::Busy,
Err(TryLockError::Poisoned(_)) => return CellRun::Poisoned,
};
// Install this cell's fresh cancel flag before running.
guard.set_cancel_flag(flag_for_cell);
CellRun::Completed(guard.eval_cell(&script))
});
let run = match tokio::time::timeout(outer_bound, join).await {
Ok(Ok(run)) => run,
Ok(Err(join_err)) => {
// The blocking task panicked — the session may be poisoned; drop it.
manager.close(&key);
tracing::error!(session_key = %key, %join_err, "[rlm] cell task panicked — session dropped");
return Err(RlmError::Internal(format!(
"rlm cell task failed: {join_err}"
)));
}
Err(_elapsed) => {
// The inner deadline should always fire first; if the outer backstop
// fires, the blocking thread may still be unwinding — never reuse it.
manager.close(&key);
tracing::error!(
session_key = %key,
outer_secs = outer_bound.as_secs(),
"[rlm] outer wall-clock backstop fired — session dropped (inner deadline should have fired first)"
);
return Err(RlmError::Timeout(
"the rlm cell exceeded its outer wall-clock backstop".to_string(),
));
}
};
let eval = match run {
CellRun::Completed(eval) => eval,
CellRun::Busy => {
tracing::info!(session_key = %key, "[rlm] session busy — concurrent cell rejected");
return Err(RlmError::SessionBusy(format!(
"rlm session '{session_id}' is already evaluating a cell; wait for it to finish or use a different session_id"
)));
}
CellRun::Poisoned => {
manager.close(&key);
return Err(RlmError::Internal(
"the rlm session was poisoned by a prior panic and has been dropped; start a fresh session".to_string(),
));
}
};
let result = match eval {
Ok(result) => result,
Err(err) => {
let mapped = map_eval_error(err, &available);
tracing::info!(session_key = %key, kind = mapped.kind(), "[rlm] cell failed");
return Err(mapped);
}
};
let stats = manager.finish_cell(&key, &result).unwrap_or_default();
if req.close_session {
manager.close(&key);
}
let response = RlmEvalResponse {
session_id,
stdout: truncate_chars(&result.stdout, MAX_RLM_RESULT_CHARS),
value: result.value.as_ref().map(|v| v.to_json()),
variables_changed: result.variables_changed,
calls: summarize_calls(&result.calls),
final_answer: result.final_answer,
elapsed_ms: result.elapsed.as_millis() as u64,
cells_used: stats.cells,
limits_remaining: RlmLimitsRemaining {
cells: policy.max_iterations.saturating_sub(stats.cells),
model_calls: policy.max_model_calls.saturating_sub(stats.model_calls),
tool_calls: policy.max_tool_calls.saturating_sub(stats.tool_calls),
agent_calls: policy.max_agent_calls.saturating_sub(stats.agent_calls),
},
closed: req.close_session,
};
tracing::info!(
session_key = %key,
elapsed_ms = response.elapsed_ms,
calls = response.calls.len(),
cells_used = response.cells_used,
"[rlm] eval_rlm_cell: done"
);
Ok(response)
}
/// Maps a raw `TinyAgentsError` from `eval_cell` onto a typed [`RlmError`],
/// enriching unknown-capability errors with the live registered names.
fn map_eval_error(err: TinyAgentsError, available: &RlmAvailable) -> RlmError {
match err {
TinyAgentsError::Validation(m) => RlmError::Script(m),
TinyAgentsError::Timeout(m) => RlmError::Timeout(m),
TinyAgentsError::LimitExceeded(m) => RlmError::LimitExceeded(m),
TinyAgentsError::Cancelled => RlmError::Cancelled,
TinyAgentsError::SubAgentDepth(n) => RlmError::Depth(format!(
"sub-agent recursion exceeded the maximum depth of {n}"
)),
TinyAgentsError::ModelNotFound(name) => RlmError::UnknownCapability(format!(
"model `{name}` is not registered. Available models: {}",
join_or_none(&available.models)
)),
TinyAgentsError::ToolNotFound(name) => RlmError::UnknownCapability(format!(
"tool `{name}` is not registered. Available tools: {}",
join_or_none(&available.tools)
)),
TinyAgentsError::Capability(m) => RlmError::UnknownCapability(format!(
"{m}. Available agents: {}",
join_or_none(&available.agents)
)),
TinyAgentsError::Tool(m) => RlmError::CapabilityError(m),
other => RlmError::Script(other.to_string()),
}
}
/// Summarizes recorded calls into the model-visible shape — kind, name, timing,
/// success only, never raw arguments/payloads.
fn summarize_calls(calls: &[ReplCallRecord]) -> Vec<RlmCallSummary> {
calls
.iter()
.map(|c| RlmCallSummary {
kind: call_kind_str(c.kind).to_string(),
name: c.name.clone(),
elapsed_ms: c.elapsed.as_millis() as u64,
ok: true,
})
.collect()
}
fn call_kind_str(kind: ReplCallKind) -> &'static str {
match kind {
ReplCallKind::Model => "model",
ReplCallKind::Tool => "tool",
ReplCallKind::Graph => "graph",
ReplCallKind::Agent => "agent",
ReplCallKind::Emit => "emit",
}
}
/// Renders a name list for an error message, or a placeholder when empty.
fn join_or_none(names: &[String]) -> String {
if names.is_empty() {
"(none)".to_string()
} else {
names.join(", ")
}
}
/// Truncates `s` to at most `max` characters (char-boundary safe), appending a
/// marker when it had to cut.
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max).collect();
out.push_str("\n… (rlm output truncated)");
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use tinyagents::harness::tool::{
Tool as TaTool, ToolCall as TaToolCall, ToolResult as TaToolResult, ToolSchema,
};
use tinyagents::registry::CapabilityRegistry;
use tinyagents::ReplPolicy;
// ── Pure-function tests (mapping / helpers) ──────────────────────────────
#[test]
fn truncate_bounds_output() {
let s = "a".repeat(100);
let out = truncate_chars(&s, 10);
assert!(out.starts_with(&"a".repeat(10)));
assert!(out.contains("truncated"));
assert!(truncate_chars("short", 10) == "short");
}
#[test]
fn error_kinds_are_stable() {
assert_eq!(RlmError::Cancelled.kind(), "cancelled");
assert_eq!(RlmError::Timeout("x".into()).kind(), "timeout");
assert!(RlmError::Script("boom".into())
.message()
.contains("Fix the script"));
}
#[test]
fn every_taxonomy_variant_maps_to_the_right_kind() {
let avail = RlmAvailable {
tools: vec!["read_file".into(), "grep".into()],
agents: vec!["researcher".into()],
models: vec!["chat".into()],
};
let cases: Vec<(TinyAgentsError, &str)> = vec![
(TinyAgentsError::Validation("parse".into()), "script_error"),
(TinyAgentsError::Timeout("t".into()), "timeout"),
(TinyAgentsError::LimitExceeded("l".into()), "limit_exceeded"),
(TinyAgentsError::Cancelled, "cancelled"),
(TinyAgentsError::SubAgentDepth(3), "recursion_depth"),
(
TinyAgentsError::ModelNotFound("m".into()),
"unknown_capability",
),
(
TinyAgentsError::ToolNotFound("x".into()),
"unknown_capability",
),
(
TinyAgentsError::Capability("agent nope".into()),
"unknown_capability",
),
(TinyAgentsError::Tool("boom".into()), "capability_error"),
];
for (err, expected) in cases {
assert_eq!(map_eval_error(err, &avail).kind(), expected);
}
// Unknown-tool errors list the live tool names for the model.
let msg = map_eval_error(TinyAgentsError::ToolNotFound("nope".into()), &avail).message();
assert!(msg.contains("read_file") && msg.contains("grep"), "{msg}");
}
// ── End-to-end `run_cell` matrix (with a hand-built registry) ────────────
struct EchoTool;
#[async_trait::async_trait]
impl TaTool<()> for EchoTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"echoes its msg argument"
}
fn schema(&self) -> ToolSchema {
ToolSchema {
name: "echo".into(),
description: "echoes".into(),
parameters: serde_json::json!({ "type": "object" }),
format: Default::default(),
}
}
async fn call(&self, _s: &(), call: TaToolCall) -> tinyagents::Result<TaToolResult> {
let msg = call
.arguments
.get("msg")
.and_then(|v| v.as_str())
.unwrap_or("");
Ok(TaToolResult::text(
call.id,
call.name,
format!("echo:{msg}"),
))
}
}
fn echo_registry() -> CapabilityRegistry<()> {
let mut registry = CapabilityRegistry::<()>::new();
registry
.register_tool(Arc::new(EchoTool))
.expect("register echo");
registry
}
fn req(script: &str) -> RlmEvalRequest {
RlmEvalRequest {
script: script.to_string(),
..Default::default()
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn happy_path_runs_a_tool_call_and_reports_usage() {
let manager = RlmSessionManager::new_for_test();
let resp = run_cell(
req(r#"tool_call(#{ tool: "echo", arguments: #{ msg: "hi" } })"#),
ReplPolicy::default(),
echo_registry(),
"t",
&manager,
)
.await
.expect("cell ok");
assert_eq!(resp.cells_used, 1);
assert_eq!(resp.calls.len(), 1);
assert_eq!(resp.calls[0].kind, "tool");
assert_eq!(resp.calls[0].name, "echo");
assert_eq!(
resp.limits_remaining.tool_calls,
ReplPolicy::default().max_tool_calls - 1
);
assert!(!resp.session_id.is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn parse_error_is_a_script_error() {
let manager = RlmSessionManager::new_for_test();
let err = run_cell(
req("let x = ;"),
ReplPolicy::default(),
echo_registry(),
"t",
&manager,
)
.await
.expect_err("parse error");
assert_eq!(err.kind(), "script_error");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unknown_tool_lists_registered_names() {
let manager = RlmSessionManager::new_for_test();
let err = run_cell(
req(r#"tool_call(#{ tool: "does_not_exist" })"#),
ReplPolicy::default(),
echo_registry(),
"t",
&manager,
)
.await
.expect_err("unknown tool");
assert_eq!(err.kind(), "unknown_capability");
assert!(err.message().contains("echo"), "{}", err.message());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn call_count_limit_fails_closed() {
let manager = RlmSessionManager::new_for_test();
let policy = ReplPolicy {
max_tool_calls: 1,
..ReplPolicy::default()
};
let err = run_cell(
req(r#"tool_call(#{tool:"echo"}); tool_call(#{tool:"echo"})"#),
policy,
echo_registry(),
"t",
&manager,
)
.await
.expect_err("limit");
assert_eq!(err.kind(), "limit_exceeded");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn script_loop_times_out_within_the_bound() {
let manager = RlmSessionManager::new_for_test();
let policy = ReplPolicy {
timeout: Some(Duration::from_millis(200)),
max_operations: 0,
..ReplPolicy::default()
};
let start = std::time::Instant::now();
let err = run_cell(req("loop {}"), policy, echo_registry(), "t", &manager)
.await
.expect_err("timeout");
assert_eq!(err.kind(), "timeout");
assert!(
start.elapsed() < Duration::from_secs(4),
"took {:?}",
start.elapsed()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn oversized_output_fails_closed() {
let manager = RlmSessionManager::new_for_test();
let policy = ReplPolicy {
max_output_bytes: 64,
..ReplPolicy::default()
};
let err = run_cell(
req(r#"for i in 0..1000 { print("xxxxxxxxxxxxxxxx"); }"#),
policy,
echo_registry(),
"t",
&manager,
)
.await
.expect_err("output limit");
assert_eq!(err.kind(), "limit_exceeded");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn namespace_persists_and_close_session_drops_it() {
let manager = RlmSessionManager::new_for_test();
let first = run_cell(
RlmEvalRequest {
script: "let acc = 10; acc".into(),
session_id: Some("keep".into()),
..Default::default()
},
ReplPolicy::default(),
echo_registry(),
"t",
&manager,
)
.await
.expect("cell 1");
assert_eq!(first.session_id, "keep");
let second = run_cell(
RlmEvalRequest {
script: "acc + 5".into(),
session_id: Some("keep".into()),
close_session: true,
..Default::default()
},
ReplPolicy::default(),
echo_registry(),
"t",
&manager,
)
.await
.expect("cell 2");
assert_eq!(second.value, Some(serde_json::json!(15)));
assert!(second.closed);
assert_eq!(manager.len(), 0, "close_session dropped the session");
}
#[tokio::test(flavor = "current_thread")]
async fn concurrent_cell_on_a_busy_session_is_rejected() {
let manager = RlmSessionManager::new_for_test();
let key = RlmSessionManager::session_key("t", "busy");
let handle = manager.get_or_create(&key, || tinyagents::ReplSession::<()>::new());
// Hold the session lock to simulate an in-flight cell.
let _guard = handle.session.lock().unwrap();
let res = run_cell(
RlmEvalRequest {
script: "1".into(),
session_id: Some("busy".into()),
..Default::default()
},
ReplPolicy::default(),
CapabilityRegistry::new(),
"t",
&manager,
)
.await;
assert!(matches!(res, Err(RlmError::SessionBusy(_))), "got {res:?}");
}
}
+203
View File
@@ -0,0 +1,203 @@
//! Maps openhuman's autonomy tier and tool-timeout clamps onto a
//! [`tinyagents::ReplPolicy`], fail-closed.
//!
//! Every RLM session is bounded: the resolved policy is always based on the
//! conservative crate default, the wall-clock timeout is always set (never
//! unbounded), the `readonly` tier is refused outright, and caller-supplied
//! limit overrides are clamped — a `full`-tier caller may raise call-count
//! limits up to a hard 2× ceiling, everyone else may only tighten them.
use std::time::Duration;
use tinyagents::ReplPolicy;
use crate::openhuman::security::policy::AutonomyLevel;
use crate::openhuman::tool_timeout;
use super::types::RlmLimitsOverride;
/// Default per-cell wall-clock timeout when the caller does not specify one.
/// Matches the `rlm` tool's default `ToolTimeout::Secs(300)` so the inner
/// deadline and the harness backstop agree.
pub const DEFAULT_RLM_TIMEOUT_SECS: u64 = 300;
/// Hard upper bound on batched concurrency, regardless of tier or override.
pub const MAX_RLM_CONCURRENCY: usize = 8;
/// Ceiling multiplier applied to the crate default call-count limits when a
/// `full`-tier caller raises them via the tool's `limits` argument.
pub const LIMIT_CEILING_MULTIPLIER: usize = 2;
/// Maximum sub-agent recursion depth an RLM session may drive. Mirrors the
/// harness `MAX_SPAWN_DEPTH` (kept in lock-step by intent; a divergence only
/// tightens one side, never opens an unbounded path).
pub const RLM_MAX_DEPTH: usize = 3;
/// Resolves a [`ReplPolicy`] for a session opened at autonomy `tier`, with an
/// optional caller `timeout_secs` and `overrides`.
///
/// Returns `Err(reason)` — a model-consumable string — when the tier forbids
/// RLM entirely (`readonly`). The `reason` is surfaced verbatim to the model.
pub fn resolve_policy(
tier: AutonomyLevel,
timeout_secs: Option<u64>,
overrides: Option<&RlmLimitsOverride>,
) -> Result<ReplPolicy, String> {
if tier == AutonomyLevel::ReadOnly {
return Err(
"the `rlm` tool is disabled at the read-only autonomy tier (it can drive tools, \
models, and sub-agents); raise autonomy to `supervised` or `full` to use it"
.to_string(),
);
}
let allow_raise = tier == AutonomyLevel::Full;
let base = ReplPolicy::default();
// Wall-clock timeout: clamp the caller's request to [1, 3600] and cap it,
// defaulting to DEFAULT_RLM_TIMEOUT_SECS. Never unbounded.
let secs =
tool_timeout::explicit_call_timeout_secs(timeout_secs, tool_timeout::MAX_TIMEOUT_SECS)
.unwrap_or(DEFAULT_RLM_TIMEOUT_SECS);
let ov = overrides.cloned().unwrap_or_default();
let policy = ReplPolicy {
timeout: Some(Duration::from_secs(secs)),
max_depth: base.max_depth.min(RLM_MAX_DEPTH),
max_model_calls: clamp_limit(base.max_model_calls, ov.max_model_calls, allow_raise),
max_agent_calls: clamp_limit(base.max_agent_calls, ov.max_agent_calls, allow_raise),
max_tool_calls: clamp_limit(base.max_tool_calls, ov.max_tool_calls, allow_raise),
max_concurrency: clamp_concurrency(base.max_concurrency, ov.max_concurrency, allow_raise),
..base
};
tracing::debug!(
tier = ?tier,
timeout_secs = secs,
max_model_calls = policy.max_model_calls,
max_agent_calls = policy.max_agent_calls,
max_tool_calls = policy.max_tool_calls,
max_concurrency = policy.max_concurrency,
max_depth = policy.max_depth,
"[rlm] resolved ReplPolicy"
);
Ok(policy)
}
/// Clamps a caller-requested call-count limit.
///
/// - No request → the crate default.
/// - `allow_raise` (full tier) → clamped to `[1, default * ceiling]`.
/// - Otherwise (supervised) → may only *tighten*: clamped to `[1, default]`.
fn clamp_limit(default: usize, requested: Option<usize>, allow_raise: bool) -> usize {
match requested {
None => default,
Some(n) => {
let ceiling = if allow_raise {
default.saturating_mul(LIMIT_CEILING_MULTIPLIER)
} else {
default
};
n.clamp(1, ceiling.max(1))
}
}
}
/// Clamps batched concurrency to `[1, MAX_RLM_CONCURRENCY]`, and to the default
/// unless the tier allows raising it.
fn clamp_concurrency(default: usize, requested: Option<usize>, allow_raise: bool) -> usize {
let ceiling = if allow_raise {
MAX_RLM_CONCURRENCY
} else {
default.min(MAX_RLM_CONCURRENCY)
};
match requested {
None => default.min(MAX_RLM_CONCURRENCY),
Some(n) => n.clamp(1, ceiling.max(1)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn readonly_tier_is_refused() {
let err =
resolve_policy(AutonomyLevel::ReadOnly, None, None).expect_err("readonly refused");
assert!(
err.contains("read-only"),
"reason should name the tier: {err}"
);
}
#[test]
fn default_timeout_when_unset_and_bounded_when_set() {
let p = resolve_policy(AutonomyLevel::Supervised, None, None).expect("policy");
assert_eq!(
p.timeout,
Some(Duration::from_secs(DEFAULT_RLM_TIMEOUT_SECS))
);
// A caller request is clamped to [1, 3600].
let p = resolve_policy(AutonomyLevel::Supervised, Some(10_000), None).expect("policy");
assert_eq!(p.timeout, Some(Duration::from_secs(3600)));
let p = resolve_policy(AutonomyLevel::Supervised, Some(0), None).expect("policy");
assert_eq!(
p.timeout,
Some(Duration::from_secs(DEFAULT_RLM_TIMEOUT_SECS))
);
}
#[test]
fn supervised_may_only_tighten_limits() {
let base = ReplPolicy::default();
let ov = RlmLimitsOverride {
max_tool_calls: Some(base.max_tool_calls * 10), // request a raise
..Default::default()
};
let p = resolve_policy(AutonomyLevel::Supervised, None, Some(&ov)).expect("policy");
assert_eq!(
p.max_tool_calls, base.max_tool_calls,
"supervised cannot raise above the default"
);
let ov = RlmLimitsOverride {
max_tool_calls: Some(1),
..Default::default()
};
let p = resolve_policy(AutonomyLevel::Supervised, None, Some(&ov)).expect("policy");
assert_eq!(p.max_tool_calls, 1, "supervised may tighten");
}
#[test]
fn full_may_raise_up_to_the_ceiling() {
let base = ReplPolicy::default();
let ov = RlmLimitsOverride {
max_model_calls: Some(base.max_model_calls * 100), // way over ceiling
..Default::default()
};
let p = resolve_policy(AutonomyLevel::Full, None, Some(&ov)).expect("policy");
assert_eq!(
p.max_model_calls,
base.max_model_calls * LIMIT_CEILING_MULTIPLIER,
"full is capped at the 2x ceiling"
);
}
#[test]
fn concurrency_is_hard_capped() {
let ov = RlmLimitsOverride {
max_concurrency: Some(1000),
..Default::default()
};
let p = resolve_policy(AutonomyLevel::Full, None, Some(&ov)).expect("policy");
assert_eq!(p.max_concurrency, MAX_RLM_CONCURRENCY);
}
#[test]
fn depth_respects_the_spawn_ceiling() {
let p = resolve_policy(AutonomyLevel::Full, None, None).expect("policy");
assert!(p.max_depth <= RLM_MAX_DEPTH);
}
}
+317
View File
@@ -0,0 +1,317 @@
//! Session manager: a process-global, bounded map of persistent `.ragsh`
//! sessions.
//!
//! Sessions are keyed `<thread_id>:<session_id>` so parallel chats never share
//! a namespace. Each session is `Send` but `eval_cell` takes `&mut self`, so a
//! session runs **one cell at a time**, serialized by a per-session
//! [`std::sync::Mutex`]; a second concurrent call on a busy session sees a
//! `try_lock` failure and returns a typed "busy" error rather than queueing
//! (see [`super::ops`]). The map is bounded fail-closed: an idle-TTL sweep plus
//! an LRU cap keep the number of live namespaces finite.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use std::time::{Duration, Instant};
use tinyagents::{ReplCallKind, ReplCancelFlag, ReplResult, ReplSession};
/// Maximum number of live sessions before the least-recently-used one is
/// evicted on the next access.
pub(super) const MAX_SESSIONS: usize = 16;
/// Idle time after which a session is evicted on the next access.
pub(super) const IDLE_TTL: Duration = Duration::from_secs(30 * 60);
/// A live session and its bookkeeping.
struct SessionSlot {
/// The session, behind a `Mutex` so only one cell runs at a time; a busy
/// `try_lock` maps to a typed "session busy" error.
session: Arc<Mutex<ReplSession>>,
/// The session's cancel flag (a clone of the one installed on the session),
/// so a run-cancellation watcher can abort an in-flight cell.
cancel: ReplCancelFlag,
/// Last time the session was accessed, for idle-TTL and LRU eviction.
last_access: Instant,
/// Cells evaluated so far (for `cells_used`).
cells: usize,
/// Cumulative capability-call counts (for `limits_remaining`, since the
/// crate does not expose a session's internal counters).
model_calls: usize,
tool_calls: usize,
agent_calls: usize,
}
/// A handle to a resolved session: the shared session and its cancel flag, plus
/// whether it was newly created this call.
pub(super) struct SlotHandle {
pub(super) session: Arc<Mutex<ReplSession>>,
pub(super) cancel: ReplCancelFlag,
pub(super) fresh: bool,
}
/// A snapshot of a session's cumulative usage after a cell, used to compute
/// `limits_remaining`.
#[derive(Debug, Clone, Copy, Default)]
pub(super) struct CellStats {
pub(super) cells: usize,
pub(super) model_calls: usize,
pub(super) tool_calls: usize,
pub(super) agent_calls: usize,
}
/// The process-global session manager.
pub(super) struct RlmSessionManager {
inner: Mutex<HashMap<String, SessionSlot>>,
}
static MANAGER: OnceLock<RlmSessionManager> = OnceLock::new();
impl RlmSessionManager {
/// Returns the process-global manager, initialising it on first use.
pub(super) fn global() -> &'static RlmSessionManager {
MANAGER.get_or_init(|| RlmSessionManager {
inner: Mutex::new(HashMap::new()),
})
}
/// Composes the map key from the parent thread scope and the session id.
pub(super) fn session_key(thread_scope: &str, session_id: &str) -> String {
format!("{thread_scope}:{session_id}")
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, SessionSlot>> {
self.inner.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Resolves the session for `key`, building a fresh one with `build` if
/// absent. Runs an eviction sweep first so idle/over-cap sessions are
/// reclaimed. The `build` closure must produce a session that already has
/// its cancel flag installed (read back via `cancel_flag()`).
pub(super) fn get_or_create(
&self,
key: &str,
build: impl FnOnce() -> ReplSession,
) -> SlotHandle {
let mut map = self.lock();
Self::evict(&mut map, key);
let now = Instant::now();
if let Some(slot) = map.get_mut(key) {
slot.last_access = now;
tracing::debug!(session_key = key, "[rlm] reusing existing session");
return SlotHandle {
session: slot.session.clone(),
cancel: slot.cancel.clone(),
fresh: false,
};
}
let session = build();
let cancel = session.cancel_flag();
let session = Arc::new(Mutex::new(session));
map.insert(
key.to_string(),
SessionSlot {
session: session.clone(),
cancel: cancel.clone(),
last_access: now,
cells: 0,
model_calls: 0,
tool_calls: 0,
agent_calls: 0,
},
);
tracing::debug!(
session_key = key,
live_sessions = map.len(),
"[rlm] created new session"
);
SlotHandle {
session,
cancel,
fresh: true,
}
}
/// Records a completed cell against `key`: bumps the cell count, accumulates
/// capability-call counts from `result`, and returns the cumulative
/// snapshot. Returns `None` if the slot was evicted mid-cell.
pub(super) fn finish_cell(&self, key: &str, result: &ReplResult) -> Option<CellStats> {
let mut map = self.lock();
let slot = map.get_mut(key)?;
slot.cells += 1;
slot.last_access = Instant::now();
for call in &result.calls {
match call.kind {
ReplCallKind::Model => slot.model_calls += 1,
ReplCallKind::Tool => slot.tool_calls += 1,
ReplCallKind::Agent => slot.agent_calls += 1,
ReplCallKind::Graph | ReplCallKind::Emit => {}
}
}
Some(CellStats {
cells: slot.cells,
model_calls: slot.model_calls,
tool_calls: slot.tool_calls,
agent_calls: slot.agent_calls,
})
}
/// Drops the session for `key` (explicit close, or on a poisoned/errored
/// session that must never be reused).
pub(super) fn close(&self, key: &str) {
if self.lock().remove(key).is_some() {
tracing::debug!(session_key = key, "[rlm] closed session");
}
}
/// Evicts idle (past [`IDLE_TTL`]) sessions, then — if still at or above the
/// [`MAX_SESSIONS`] cap and `incoming` is not already present — the
/// least-recently-used session, so inserting `incoming` stays within cap.
fn evict(map: &mut HashMap<String, SessionSlot>, incoming: &str) {
let now = Instant::now();
map.retain(|k, slot| {
let keep = now.duration_since(slot.last_access) < IDLE_TTL;
if !keep {
tracing::debug!(session_key = %k, "[rlm] evicting idle session (TTL)");
}
keep
});
if map.contains_key(incoming) || map.len() < MAX_SESSIONS {
return;
}
// Drop the least-recently-used slot to make room for the incoming one.
if let Some(lru_key) = map
.iter()
.min_by_key(|(_, slot)| slot.last_access)
.map(|(k, _)| k.clone())
{
map.remove(&lru_key);
tracing::debug!(session_key = %lru_key, "[rlm] evicting LRU session (cap)");
}
}
/// Number of live sessions (for tests/diagnostics).
#[cfg(test)]
pub(super) fn len(&self) -> usize {
self.lock().len()
}
/// A standalone (non-global) manager instance, so tests are isolated from
/// the process-global singleton and from each other.
#[cfg(test)]
pub(super) fn new_for_test() -> Self {
RlmSessionManager {
inner: Mutex::new(HashMap::new()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tinyagents::{ReplPolicy, ReplSession, ReplValue};
fn build_session() -> ReplSession {
ReplSession::<()>::new()
}
#[test]
fn namespace_persists_across_cells_in_one_session() {
let manager = RlmSessionManager::new_for_test();
let key = RlmSessionManager::session_key("t", "s1");
let handle = manager.get_or_create(&key, build_session);
assert!(handle.fresh);
handle
.session
.lock()
.unwrap()
.eval_cell("let n = 7;")
.expect("cell 1");
// Reusing the same key returns the same (non-fresh) session, so the
// binding is still visible.
let handle = manager.get_or_create(&key, build_session);
assert!(!handle.fresh);
let result = handle
.session
.lock()
.unwrap()
.eval_cell("n + 1")
.expect("cell 2");
assert_eq!(result.value, Some(ReplValue::Int(8)));
}
#[test]
fn distinct_session_keys_are_isolated() {
let manager = RlmSessionManager::new_for_test();
let a = manager.get_or_create(&RlmSessionManager::session_key("t", "a"), build_session);
a.session.lock().unwrap().eval_cell("let x = 1;").unwrap();
// A different key has its own namespace, so `x` is undefined there.
let b = manager.get_or_create(&RlmSessionManager::session_key("t", "b"), build_session);
assert!(b.session.lock().unwrap().eval_cell("x").is_err());
}
#[test]
fn thread_scope_isolates_the_same_session_id() {
let manager = RlmSessionManager::new_for_test();
let k1 = RlmSessionManager::session_key("thread-1", "shared");
let k2 = RlmSessionManager::session_key("thread-2", "shared");
assert_ne!(k1, k2);
manager.get_or_create(&k1, build_session);
let h2 = manager.get_or_create(&k2, build_session);
assert!(
h2.fresh,
"same session_id under a different thread is a fresh namespace"
);
}
#[test]
fn lru_cap_bounds_the_number_of_live_sessions() {
let manager = RlmSessionManager::new_for_test();
for i in 0..(MAX_SESSIONS + 5) {
manager.get_or_create(
&RlmSessionManager::session_key("t", &format!("s{i}")),
build_session,
);
}
assert!(
manager.len() <= MAX_SESSIONS,
"live sessions {} exceeded the cap {MAX_SESSIONS}",
manager.len()
);
}
#[test]
fn close_drops_the_session() {
let manager = RlmSessionManager::new_for_test();
let key = RlmSessionManager::session_key("t", "closeme");
manager.get_or_create(&key, build_session);
assert_eq!(manager.len(), 1);
manager.close(&key);
assert_eq!(manager.len(), 0);
// Re-creating after close is a fresh session.
assert!(manager.get_or_create(&key, build_session).fresh);
}
#[test]
fn finish_cell_accumulates_and_bounds_are_reported() {
let manager = RlmSessionManager::new_for_test();
let key = RlmSessionManager::session_key("t", "stats");
let policy = ReplPolicy::default();
let handle = manager.get_or_create(&key, || {
ReplSession::<()>::new().with_policy(policy.clone())
});
let result = handle
.session
.lock()
.unwrap()
.eval_cell("emit(\"hi\"); 1")
.expect("cell");
let stats = manager.finish_cell(&key, &result).expect("stats");
assert_eq!(stats.cells, 1);
// `emit` is not a model/tool/agent call, so those stay zero.
assert_eq!(stats.tool_calls, 0);
assert_eq!(stats.model_calls, 0);
}
}
+255
View File
@@ -0,0 +1,255 @@
//! The first-class `rlm` tool — the orchestrator's language-workflow surface.
//!
//! One tool call evaluates one Rhai cell against a persistent session
//! namespace. All effectful work a cell performs goes through the bridged inner
//! tools/models/sub-agents (each carrying its own approval/permission gates in
//! [`super::bridge`]), so the `rlm` tool itself declares no external effect.
use async_trait::async_trait;
use serde_json::{json, Value};
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult, ToolScope, ToolTimeout};
use super::policy::DEFAULT_RLM_TIMEOUT_SECS;
use super::types::RlmEvalRequest;
/// The `rlm` language-workflow tool. Stateless: it resolves the parent turn
/// context, autonomy tier, and cancellation per call.
pub struct RlmTool;
impl RlmTool {
/// Builds the tool.
pub fn new() -> Self {
Self
}
}
impl Default for RlmTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for RlmTool {
fn name(&self) -> &str {
"rlm"
}
fn description(&self) -> &str {
"Author and run a small Rhai workflow *cell* to orchestrate ad-hoc control flow — \
fan-out, loops, dedup/verify pipelines — that fixed spawn/parallel tools cannot express. \
One call evaluates one cell; top-level `let` bindings persist within a `session_id` so a \
later cell continues the same namespace.\n\
\n\
In-cell built-ins:\n\
• tool_call(#{tool, arguments}) — call one of your visible tools; returns its content.\n\
• agent_query(#{agent, prompt}) — spawn a sub-agent (by id from your allowlist); returns its text.\n\
• model_query(#{model, system?, prompt?}) — one model completion; returns the text.\n\
• tool_call_batched([...]) / agent_query_batched([...]) / model_query_batched([...]) — bounded-concurrency fan-out; each returns an array aligned with its input.\n\
• emit(name, #{..}) — record a progress event. answer(text) — set the cell's final answer.\n\
• print(x) — captured into stdout. Ordinary Rhai: let, if, for/while, arrays #[..], maps #{..}.\n\
\n\
Bounds (fail-closed): per-cell wall-clock timeout, and per-session caps on model/tool/agent \
calls, iterations, output bytes, and recursion depth. Exceeding one returns an error you can \
fix and retry in the same session. Excluded from cells: `rlm` itself, `spawn_*`, and workflow tools.\n\
\n\
Example — parallel fan-out over sub-agents, then reduce:\n\
```\n\
let topics = [\"auth\", \"billing\", \"search\"];\n\
let findings = agent_query_batched(topics.map(|t| #{ agent: \"researcher\", prompt: `Investigate ${t}` }));\n\
for f in findings { print(f); }\n\
answer(`Reviewed ${findings.len()} areas`);\n\
```\n\
\n\
Example — batched tool calls, keep only the hits:\n\
```\n\
let files = [\"a.rs\", \"b.rs\", \"c.rs\"];\n\
let reads = tool_call_batched(files.map(|p| #{ tool: \"read_file\", arguments: #{ path: p } }));\n\
let hits = [];\n\
for r in reads { if r.ok && r.content.contains(\"TODO\") { hits.push(r.content); } }\n\
hits\n\
```\n\
\n\
Prefer `rlm` over spawn_parallel_agents when you need loops, conditionals, or a dedup/verify \
pipeline over results. Pass a `session_id` to continue a prior cell's bindings; set \
`close_session: true` when done."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"script": {
"type": "string",
"description": "Rhai workflow cell to evaluate. Top-level `let` bindings persist within a session_id."
},
"session_id": {
"type": "string",
"description": "Continue a prior RLM session's namespace; omit for a fresh session (its generated id is returned)."
},
"timeout_secs": {
"type": "integer",
"minimum": 1,
"maximum": 3600,
"description": "Per-cell wall-clock timeout in seconds (default 300)."
},
"limits": {
"type": "object",
"description": "Optional per-session limit overrides (clamped; only the `full` autonomy tier may raise above defaults).",
"properties": {
"max_tool_calls": { "type": "integer" },
"max_agent_calls": { "type": "integer" },
"max_model_calls": { "type": "integer" },
"max_concurrency": { "type": "integer" }
}
},
"close_session": {
"type": "boolean",
"description": "Close (drop) the session after this cell."
}
},
"required": ["script"]
})
}
fn permission_level(&self) -> PermissionLevel {
// Matches spawn_subagent: an orchestration primitive that can drive
// effectful inner tools (each of which re-gates itself).
PermissionLevel::Execute
}
fn scope(&self) -> ToolScope {
// Orchestrator surface only; not exposed over CLI/RPC in v1.
ToolScope::AgentOnly
}
fn timeout_policy(&self, args: &Value) -> ToolTimeout {
// Always an explicit bound (never Inherit/Unbounded): a legitimate
// fan-out outlives the default inherit budget, but must still be capped.
let requested = args.get("timeout_secs").and_then(Value::as_u64);
let secs = crate::openhuman::tool_timeout::explicit_call_timeout_secs(
requested,
crate::openhuman::tool_timeout::MAX_TIMEOUT_SECS,
)
.unwrap_or(DEFAULT_RLM_TIMEOUT_SECS);
ToolTimeout::Secs(secs)
}
fn display_label(&self, _args: &Value) -> Option<String> {
Some("running RLM workflow".to_string())
}
fn display_detail(&self, args: &Value) -> Option<String> {
let script = args.get("script").and_then(Value::as_str)?;
let first = script
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("")
.trim();
if first.chars().count() > 80 {
Some(format!("{}", first.chars().take(79).collect::<String>()))
} else {
Some(first.to_string())
}
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let req: RlmEvalRequest = match serde_json::from_value(args) {
Ok(req) => req,
Err(err) => {
return Ok(ToolResult::error(format!(
"rlm: invalid arguments: {err}. Required: `script` (string). \
Optional: session_id, timeout_secs (13600), limits, close_session."
)));
}
};
if req.script.trim().is_empty() {
return Ok(ToolResult::error(
"rlm: `script` is required and must be a non-empty Rhai cell.",
));
}
match super::ops::eval_rlm_cell(req).await {
Ok(response) => match serde_json::to_value(&response) {
Ok(value) => Ok(ToolResult::json(value)),
Err(err) => Ok(ToolResult::error(format!(
"rlm: internal error rendering result: {err}"
))),
},
Err(err) => {
log::info!("[rlm] tool returning error result (kind={})", err.kind());
Ok(ToolResult::error(err.message()))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metadata_is_stable() {
let tool = RlmTool::new();
assert_eq!(tool.name(), "rlm");
assert_eq!(tool.permission_level(), PermissionLevel::Execute);
assert!(matches!(tool.scope(), ToolScope::AgentOnly));
assert!(!tool.external_effect());
}
#[test]
fn timeout_is_always_bounded() {
let tool = RlmTool::new();
assert_eq!(
tool.timeout_policy(&json!({})),
ToolTimeout::Secs(DEFAULT_RLM_TIMEOUT_SECS)
);
assert_eq!(
tool.timeout_policy(&json!({ "timeout_secs": 42 })),
ToolTimeout::Secs(42)
);
// Out-of-range requests are clamped, never unbounded.
assert_eq!(
tool.timeout_policy(&json!({ "timeout_secs": 100000 })),
ToolTimeout::Secs(3600)
);
}
#[test]
fn display_detail_elides_first_script_line() {
let tool = RlmTool::new();
let detail = tool
.display_detail(&json!({ "script": "\n\nlet x = 1;\nlet y = 2;" }))
.expect("detail");
assert_eq!(detail, "let x = 1;");
}
#[tokio::test]
async fn empty_script_is_a_model_consumable_error() {
let tool = RlmTool::new();
let result = tool
.execute(json!({ "script": " " }))
.await
.expect("ok result");
assert!(result.is_error);
}
#[tokio::test]
async fn missing_script_is_a_model_consumable_error() {
let tool = RlmTool::new();
// No `script` field at all → invalid arguments, not a panic.
let result = tool
.execute(json!({ "session_id": "x" }))
.await
.expect("ok result");
assert!(result.is_error);
assert!(result.output_for_llm(false).contains("script"));
}
#[test]
fn schema_requires_script() {
let schema = RlmTool::new().parameters_schema();
assert_eq!(schema["required"], json!(["script"]));
}
}
+129
View File
@@ -0,0 +1,129 @@
//! Serde domain types for the `rlm` language-workflow tool.
//!
//! These cross the tool boundary (parsed from the `rlm` tool call, and rendered
//! back into its [`ToolResult`](crate::openhuman::tools::ToolResult)). The
//! runtime types that wire a session to openhuman's tools/models/subagents live
//! in [`super::bridge`] and [`super::sessions`]; the tinyagents-side limit and
//! result types live in that crate.
use serde::{Deserialize, Serialize};
/// A caller-supplied RLM session id. Continuing a prior `session_id` reuses that
/// session's persistent namespace (`let` bindings survive across cells); an
/// absent id starts a fresh session. Namespaces are additionally scoped by the
/// parent thread inside [`super::sessions`], so two chats never collide.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RlmSessionId(pub String);
impl RlmSessionId {
/// The session id as a string slice.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for RlmSessionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// Per-call limit overrides a caller may pass in the `rlm` tool's `limits`
/// argument. Each is clamped in [`super::policy`] to a hard ceiling (never
/// unbounded); a `full`-tier caller may raise them, others are capped at the
/// conservative defaults.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RlmLimitsOverride {
/// Requested `max_tool_calls` for the session.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tool_calls: Option<usize>,
/// Requested `max_agent_calls` for the session.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_agent_calls: Option<usize>,
/// Requested `max_model_calls` for the session.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_model_calls: Option<usize>,
/// Requested `max_concurrency` for batched calls (hard-capped at 8).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrency: Option<usize>,
}
/// A parsed `rlm` tool call — one cell to evaluate against a session.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct RlmEvalRequest {
/// The Rhai workflow cell to evaluate.
pub script: String,
/// Continue a prior session's namespace; `None` starts a fresh session.
#[serde(default)]
pub session_id: Option<String>,
/// Per-cell wall-clock timeout in seconds (clamped 13600).
#[serde(default)]
pub timeout_secs: Option<u64>,
/// Optional per-session limit overrides.
#[serde(default)]
pub limits: Option<RlmLimitsOverride>,
/// Close (drop) the session after this cell.
#[serde(default)]
pub close_session: bool,
}
/// A summarized capability call a cell performed — kind, name, timing, and
/// success only. Never the raw arguments or payloads (those stay at `debug` log
/// level and on the live event stream), so a model-visible result cannot leak a
/// large or sensitive payload back into the context window.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RlmCallSummary {
/// `model` | `tool` | `agent` | `graph` | `emit`.
pub kind: String,
/// The capability or event name.
pub name: String,
/// Wall-clock time the call took, in milliseconds.
pub elapsed_ms: u64,
/// Whether the call was recorded (calls that errored abort the cell, so a
/// recorded call is a completed one).
pub ok: bool,
}
/// The remaining per-session budget after a cell, surfaced so the model can plan
/// how much more fan-out it can do before splitting work across sessions.
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct RlmLimitsRemaining {
/// Cells left before `max_iterations`.
pub cells: usize,
/// `model_query` calls left.
pub model_calls: usize,
/// `tool_call` calls left.
pub tool_calls: usize,
/// `agent_query` calls left.
pub agent_calls: usize,
}
/// The structured result of evaluating one RLM cell, rendered into the JSON
/// content of the tool result.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RlmEvalResponse {
/// The session the cell ran in (echoed so a fresh session's generated id is
/// discoverable, and a later cell can continue it).
pub session_id: String,
/// Captured `print`/`debug` output.
pub stdout: String,
/// The cell's final expression value, if any.
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<serde_json::Value>,
/// Names of persistent variables the cell created or changed.
pub variables_changed: Vec<String>,
/// Summarized capability calls the cell performed.
pub calls: Vec<RlmCallSummary>,
/// The final answer, if the cell called `answer(...)`.
#[serde(skip_serializing_if = "Option::is_none")]
pub final_answer: Option<String>,
/// Wall-clock time the cell took, in milliseconds.
pub elapsed_ms: u64,
/// Number of cells this session has evaluated so far.
pub cells_used: usize,
/// Remaining per-session budget.
pub limits_remaining: RlmLimitsRemaining,
/// Whether the session was closed after this cell.
#[serde(default)]
pub closed: bool,
}
+3 -3
View File
@@ -25,7 +25,7 @@ pub(crate) mod delegation;
mod embeddings;
pub(crate) mod journal;
pub(crate) mod middleware;
mod model;
pub(crate) mod model;
pub(crate) mod observability;
pub(crate) mod orchestration;
pub(crate) mod payload_summarizer;
@@ -33,12 +33,12 @@ mod policy_denial;
pub(crate) mod replay;
pub(crate) mod retriever;
mod routes;
mod run_cancellation_context;
pub(crate) mod run_cancellation_context;
mod steering_forwarder;
pub(crate) mod stop_hooks;
pub(crate) mod subagent_graph;
mod summarize;
mod tools;
pub(crate) mod tools;
mod topology;
use std::sync::Arc;
+13
View File
@@ -364,6 +364,19 @@ pub(super) struct ProviderModel {
profile: ModelProfile,
}
/// Builds a `ChatModel<()>` capability over an openhuman [`Provider`], pinned to
/// `model`/`temperature`, ready to register into a `tinyagents`
/// [`CapabilityRegistry`](tinyagents::registry::CapabilityRegistry) — e.g. the
/// `.ragsh` REPL bridge (`crate::openhuman::rlm`). [`ProviderModel::new`] is
/// `pub(super)`; this is the crate-visible factory for that one bridge use.
pub(crate) fn provider_chat_model(
provider: Arc<dyn Provider>,
model: impl Into<String>,
temperature: f64,
) -> Arc<dyn ChatModel<()>> {
Arc::new(ProviderModel::new(provider, model, temperature))
}
impl ProviderModel {
/// Build a model adapter for `provider`, pinned to `model`/`temperature`.
///
+4 -2
View File
@@ -120,7 +120,9 @@ impl Tool<()> for ToolAdapter {
}
}
fn tool_policy_from_openhuman_tool(tool: &dyn crate::openhuman::tools::Tool) -> ToolPolicy {
pub(crate) fn tool_policy_from_openhuman_tool(
tool: &dyn crate::openhuman::tools::Tool,
) -> ToolPolicy {
use crate::openhuman::tools::traits::ToolTimeout;
use crate::openhuman::tools::PermissionLevel;
@@ -181,7 +183,7 @@ const TINYAGENTS_TOOL_SESSION: &str = "tinyagents";
/// Execute an openhuman [`Tool`](crate::openhuman::tools::Tool) for a harness
/// [`TaToolCall`] and render the [`TaToolResult`] the way the LLM should see it
/// (mirrors the live-path `HarnessToolExecutor`).
async fn execute_openhuman_tool(
pub(crate) async fn execute_openhuman_tool(
tool: &dyn crate::openhuman::tools::Tool,
call: TaToolCall,
context: Option<&ToolExecutionContext>,
+1
View File
@@ -38,6 +38,7 @@ pub use crate::openhuman::monitor::tools::*;
pub use crate::openhuman::orchestration::tools::*;
pub use crate::openhuman::people::tools::*;
pub use crate::openhuman::referral::tools::*;
pub use crate::openhuman::rlm::tools::*;
pub use crate::openhuman::screen_intelligence::tools::*;
pub use crate::openhuman::search::tools::*;
pub use crate::openhuman::security::tools::*;
+23
View File
@@ -966,6 +966,29 @@ pub fn all_tools_with_runtime(
tracing::debug!("[lsp] capability gate off (set OPENHUMAN_LSP_ENABLED=1 to register)");
}
// Language-workflow `rlm` tool (RLM / `.ragsh` REPL, `openhuman::rlm`): lets
// the orchestrator author and run its own Rhai workflow cells (fan-out,
// loops, dedup/verify pipelines). Registered on the `supervised`/`full`
// tiers only — dark on `readonly` (it can drive effectful tools/sub-agents)
// and behind the `OPENHUMAN_RLM=0` kill switch. Every effectful inner call
// still re-gates itself in the RLM bridge, so this surface adds no new
// ungated capability.
let rlm_enabled = std::env::var("OPENHUMAN_RLM")
.map(|v| v != "0")
.unwrap_or(true);
if rlm_enabled
&& security.autonomy != crate::openhuman::security::policy::AutonomyLevel::ReadOnly
{
tools.push(Box::new(crate::openhuman::rlm::RlmTool::new()));
tracing::debug!("[rlm] registered rlm language-workflow tool");
} else {
tracing::debug!(
rlm_enabled,
tier = ?security.autonomy,
"[rlm] rlm tool not registered (readonly tier or OPENHUMAN_RLM=0)"
);
}
tools
}