mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
refactor(rhai): rename language workflow domain (#4571)
This commit is contained in:
+1
-1
@@ -64,7 +64,7 @@ tinyjuice = { version = "0.1", default-features = false }
|
||||
# Durable graph checkpoints still use `SqlRunLedgerCheckpointer` until the
|
||||
# migration re-points those rows to the crate checkpointer.
|
||||
# The `repl` feature adds the embedded Rhai `.ragsh` session runtime powering
|
||||
# the `rlm` language-workflow tool (`src/openhuman/rlm/`).
|
||||
# the `rhai_workflows` language-workflow tool (`src/openhuman/rhai_workflows/`).
|
||||
tinyagents = { version = "1.7", 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
|
||||
|
||||
@@ -69,7 +69,7 @@ The repo has **three** "workflow" systems. This plan touches only the first:
|
||||
|
||||
1. `flows::` / `openhuman.flows_*` — **tinyflows typed graphs** (this plan).
|
||||
2. `workflows::` / `openhuman.workflows_*` — WORKFLOW.md/SKILL.md bundle discovery/install (separate product surface under `/skills`). **Slated for decommission** — it is essentially the skills feature wearing the "workflows" name; see Phase 8. Retiring it frees the "Workflows" branding for tinyflows (the `/flows` nav tab already reads "Workflows").
|
||||
3. `rlm::` — Rhai `.ragsh` language workflows (`docs/plans/rlm-workflows/`), positioned in `gitbooks/features/orchestration.md` as the _next_ layer on the same substrate. tinyflows remains the shipping visual/typed product; rlm does not replace it.
|
||||
3. `rhai::` — Rhai `.ragsh` language workflows (`docs/plans/rlm-workflows/`), positioned in `gitbooks/features/orchestration.md` as the _next_ layer on the same substrate. tinyflows remains the shipping visual/typed product; Rhai workflows do not replace it.
|
||||
|
||||
---
|
||||
|
||||
@@ -113,16 +113,16 @@ Every workflow is, at run time, a **unique tinyagents state graph**: `compile()`
|
||||
Audit (2026-07-04):
|
||||
|
||||
- ✅ **Single unified copy** — both Cargo worlds (`Cargo.toml:355`, `app/src-tauri/Cargo.toml:214`) patch `tinyagents` to `vendor/tinyagents`; exactly one `tinyagents 1.5.0` in both lockfiles. tinyflows' `tinyagents = "1.2"` requirement unifies onto the same copy (semver-compatible). No duplication, no type-identity risk.
|
||||
- ⚠️ **Not on a tag** — the submodule is pinned at `df391c4` = `v1.5.0-13-gdf391c4`: 13 untagged commits past v1.5.0 (REPL host-embedding / cancel work taken early for the rlm feature). The crate still self-reports `1.5.0`, so the version string understates the vendored code.
|
||||
- ⚠️ **Not on a tag** — the submodule is pinned at `df391c4` = `v1.5.0-13-gdf391c4`: 13 untagged commits past v1.5.0 (REPL host-embedding / cancel work taken early for the Rhai workflow feature). The crate still self-reports `1.5.0`, so the version string understates the vendored code.
|
||||
- ⚠️ **Two minor versions behind** — upstream is at **v1.7.1** (60 commits past v1.5.0). The 13 early-adopted commits all landed upstream (via PR #19 etc.; `ReplSession::set_cancel_flag` verified present in v1.7.1), so retagging loses nothing.
|
||||
- ⚠️ Same pattern on tinyflows itself: submodule at `v0.3.0-1-g438f8fc` (one commit past tag).
|
||||
|
||||
Work items:
|
||||
|
||||
1. Bump `vendor/tinyagents` submodule to the **v1.7.1 tag**; bump root requirement `tinyagents = { version = "1.7", features = ["sqlite", "repl"] }` (verify both features still exist in 1.7); `cargo update -p tinyagents` in both lockfiles.
|
||||
2. Review the v1.5→v1.7 changelog for API breaks in the seams we touch: `Checkpointer`/`DurabilityMode`, `GraphEventJournal`/`GraphObservation`, interrupt/resume semantics (used by `flows::ops` + `src/openhuman/tinyagents/` + rlm).
|
||||
2. Review the v1.5→v1.7 changelog for API breaks in the seams we touch: `Checkpointer`/`DurabilityMode`, `GraphEventJournal`/`GraphObservation`, interrupt/resume semantics (used by `flows::ops` + `src/openhuman/tinyagents/` + Rhai workflows).
|
||||
3. Retag `vendor/tinyflows` on a proper release (v0.3.1) whose `Cargo.toml` requires `tinyagents = "1.7"` so the version story is coherent end-to-end.
|
||||
4. Gate with `cargo check` both worlds, `pnpm test:rust`, and the flows/rlm unit suites; adopt a standing rule: **vendored tiny\* submodules pin release tags, never floating commits** (early-adopting an upstream PR requires a pre-release tag).
|
||||
4. Gate with `cargo check` both worlds, `pnpm test:rust`, and the flows/Rhai unit suites; adopt a standing rule: **vendored tiny\* submodules pin release tags, never floating commits** (early-adopting an upstream PR requires a pre-release tag).
|
||||
|
||||
### Phase 1 — Backend completion (triggers, lifecycle, observability)
|
||||
|
||||
@@ -255,7 +255,7 @@ Tracked separately since it's a submodule with its own release cadence (host pin
|
||||
|
||||
The `workflows::` domain (WORKFLOW.md/SKILL.md bundle discovery/install, RPC `openhuman.workflows_*`) predates tinyflows and is functionally the **skills** feature under a different name. Keeping two things called "workflows" confuses users, agents, and contributors alike. Plan: fold what's unique into `skills`, delete the rest, and hand the name to tinyflows.
|
||||
|
||||
- **Audit consumers first**: `agent/tools/run_workflow.rs` (agent tool that runs WORKFLOW.md bundles — decide: retire, or repoint to `flows_run`/skills), the `/skills` UI surfaces (`WorkflowsTab`, `CreateWorkflowForm`, `WorkflowRunnerBody`, `WorkflowNew.tsx`, `WorkflowsRun.tsx`, `DevWorkflowPanel`, `workflowsApi.ts`), `about_app`, gitbooks, and the rlm plan's references to `run_workflow` as a composition surface.
|
||||
- **Audit consumers first**: `agent/tools/run_workflow.rs` (agent tool that runs WORKFLOW.md bundles — decide: retire, or repoint to `flows_run`/skills), the `/skills` UI surfaces (`WorkflowsTab`, `CreateWorkflowForm`, `WorkflowRunnerBody`, `WorkflowNew.tsx`, `WorkflowsRun.tsx`, `DevWorkflowPanel`, `workflowsApi.ts`), `about_app`, gitbooks, and the Rhai workflow plan's references to `run_workflow` as a composition surface.
|
||||
- **Migrate**: bundle discovery/install semantics that skills doesn't already cover move into the `skills` domain (it is metadata-only post-QuickJS-removal, so this is mostly file-format and registry work).
|
||||
- **Deprecate then delete**: mark `openhuman.workflows_*` deprecated for one release (RPC responses carry a deprecation notice), then remove `src/openhuman/workflows/`, its controllers from `src/core/all.rs`, the frontend clients/pages, and the `/workflows/new`//`workflows/run` routes (bare `/workflows` already redirects to `/settings/automations`).
|
||||
- **Not in scope**: `openhuman.workflow_run_*` (`agent_orchestration`'s declarative run ledger) is a different system and untouched here — though its name should also be revisited once "Workflows" ≡ tinyflows.
|
||||
|
||||
@@ -54,7 +54,7 @@ OpenHuman pins `tinyagents = { version = "1.5.0", features = ["sqlite", "repl"]
|
||||
- **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` 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).
|
||||
- **`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 `rhai_workflows` language-workflow tool ([`openhuman::rhai_workflows`](../../../src/openhuman/rhai_workflows/README.md), see "Language workflows (Rhai)" 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,20 +282,20 @@ 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`)
|
||||
## Language workflows (Rhai)
|
||||
|
||||
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.
|
||||
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 **`rhai_workflows` 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):
|
||||
The domain lives in [`src/openhuman/rhai_workflows/`](../../../src/openhuman/rhai_workflows/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.
|
||||
- **`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 (`rhai`, legacy `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.
|
||||
The tool is registered for the orchestrator on `supervised`/`full` tiers only, behind the `OPENHUMAN_RHAI_WORKFLOWS=0` kill switch; `OPENHUMAN_RHAI=0` and `OPENHUMAN_RLM=0` remain legacy aliases. The TinyAgents-side host-embedding support (external cancellation, live capability events) landed in that crate's `repl` feature.
|
||||
|
||||
## Triage - handling external triggers
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ Because the transport is plain Signal-encrypted messaging, the other side doesn'
|
||||
|
||||
## What's next: RLMs
|
||||
|
||||
The direction we're building toward: **RLM-style language-based workflows**. These are agents that express orchestration as small programs in a sandboxed REPL, rather than a fixed graph, so control flow itself becomes something the model writes, inspects, and repairs. The graph engine, checkpointing, and trust model above are the substrate for it.
|
||||
The direction we're building toward: **Rhai-backed language workflows**. These are agents that express orchestration as small programs in a sandboxed REPL, rather than a fixed graph, so control flow itself becomes something the model writes, inspects, and repairs. The graph engine, checkpointing, and trust model above are the substrate for it.
|
||||
|
||||
***
|
||||
|
||||
|
||||
@@ -622,11 +622,11 @@ pub(super) const CAPABILITIES: &[Capability] = &[
|
||||
},
|
||||
Capability {
|
||||
id: "intelligence.language_workflows",
|
||||
name: "Language Workflows (RLM)",
|
||||
name: "Language Workflows (Rhai)",
|
||||
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",
|
||||
how_to: "Runs automatically when the orchestrator chooses the `rhai_workflows` tool; disable with OPENHUMAN_RHAI_WORKFLOWS=0 or the read-only autonomy tier",
|
||||
status: CapabilityStatus::Beta,
|
||||
privacy: DERIVED_TO_BACKEND,
|
||||
},
|
||||
|
||||
@@ -103,11 +103,11 @@ 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)
|
||||
## Language workflows (Rhai)
|
||||
|
||||
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.
|
||||
the fixed `spawn_parallel_agents` / `delegate_*` primitives can't express, use the `rhai_workflows` 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`).
|
||||
@@ -115,11 +115,11 @@ It evaluates a small **Rhai workflow cell** whose only side effects are capabili
|
||||
- **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
|
||||
- **Prefer `rhai_workflows`** 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
|
||||
`agent_query` only reaches sub-agents already in your allowlist. `rhai` 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
|
||||
|
||||
@@ -98,7 +98,7 @@ pub mod provider_surfaces;
|
||||
pub mod recall_calendar;
|
||||
pub mod redirect_links;
|
||||
pub mod referral;
|
||||
pub mod rlm;
|
||||
pub mod rhai_workflows;
|
||||
pub mod routing;
|
||||
pub mod runtime_node;
|
||||
pub mod runtime_python;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# `rlm` — language-based workflows (Rhai `.ragsh` REPL)
|
||||
# `rhai` — 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
|
||||
feature, `tinyagents::ReplSession`) as a first-class **`rhai_workflows` 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
|
||||
@@ -17,12 +17,12 @@ the tool result. The orchestrator's own turn loop *is* the CodeAct driver loop.
|
||||
| File | Role |
|
||||
| ---- | ---- |
|
||||
| `mod.rs` | Exports only (no controller schemas in v1). |
|
||||
| `types.rs` | `RlmSessionId`, `RlmEvalRequest`/`RlmEvalResponse`, `RlmLimitsOverride`, `RlmCallSummary`, serde types. |
|
||||
| `types.rs` | `RhaiSessionId`, `RhaiEvalRequest`/`RhaiEvalResponse`, `RhaiLimitsOverride`, `RhaiCallSummary`, 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). |
|
||||
| `sessions.rs` | `RhaiSessionManager`: LRU + idle-TTL bounded map of persistent `ReplSession`s, keyed `<thread>:<session_id>`, one cell at a time. |
|
||||
| `ops.rs` | `eval_rhai_cell`: spawn_blocking + outer timeout, cancellation wiring, event forwarding, error → model-consumable result. |
|
||||
| `tools.rs` | `RhaiTool` (the `rhai_workflows` tool: schema, permission, scope, timeout, display). |
|
||||
|
||||
## Fail-closed guarantees
|
||||
|
||||
@@ -47,8 +47,8 @@ never a hung turn:
|
||||
|
||||
## 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`
|
||||
The Rhai bridge restricts callable tools to the parent turn's
|
||||
`visible_tool_names`, and **excludes** recursion/duplication hazards: `rhai`
|
||||
itself, `spawn_subagent`/`spawn_parallel_agents` (use `agent_query` instead),
|
||||
and `run_workflow`/`await_workflow`. `ToolScope::CliRpcOnly` tools are denied.
|
||||
|
||||
@@ -66,5 +66,6 @@ and `answer`. Graph authoring/execution (`graph_*`) is **out of scope for v1**
|
||||
## 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.
|
||||
`OPENHUMAN_RHAI_WORKFLOWS=0`; default-on for `supervised`/`full`.
|
||||
`OPENHUMAN_RHAI=0` and `OPENHUMAN_RLM=0` are still honoured as legacy aliases.
|
||||
Reverting the registration line disables the surface without touching the domain.
|
||||
@@ -4,7 +4,7 @@
|
||||
//!
|
||||
//! Three capability kinds are wired, each keeping openhuman's own gates:
|
||||
//!
|
||||
//! - **Tools** — one [`RlmToolAdapter`] per visible, non-excluded, agent-scoped
|
||||
//! - **Tools** — one [`RhaiToolAdapter`] 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`
|
||||
@@ -15,7 +15,7 @@
|
||||
//! `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`
|
||||
//! Recursion/duplication hazards are **excluded** from the tool surface: `rhai`
|
||||
//! itself (no REPL-in-REPL), `spawn_*` (use `agent_query`), and
|
||||
//! `run_workflow`/`await_workflow`. `ToolScope::CliRpcOnly` tools are excluded
|
||||
//! too.
|
||||
@@ -49,7 +49,9 @@ use crate::openhuman::tools::Tool as OhTool;
|
||||
/// 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 == "rhai_workflows"
|
||||
|| name == "rhai"
|
||||
|| name == "rlm"
|
||||
|| name == "run_workflow"
|
||||
|| name == "await_workflow"
|
||||
|| name.starts_with("spawn_")
|
||||
@@ -62,6 +64,8 @@ mod tests {
|
||||
#[test]
|
||||
fn recursion_and_duplication_hazards_are_excluded() {
|
||||
for name in [
|
||||
"rhai",
|
||||
"rhai_workflows",
|
||||
"rlm",
|
||||
"run_workflow",
|
||||
"await_workflow",
|
||||
@@ -81,7 +85,7 @@ mod tests {
|
||||
/// 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
|
||||
/// allowlist. The returned registry carries no `rhai`, `spawn_*`, or workflow
|
||||
/// tools, and no `CliRpcOnly`-scoped tools.
|
||||
pub(super) fn build_capability_registry(parent: &ParentExecutionContext) -> CapabilityRegistry<()> {
|
||||
let mut registry = CapabilityRegistry::<()>::new();
|
||||
@@ -107,7 +111,7 @@ pub(super) fn build_capability_registry(parent: &ParentExecutionContext) -> Capa
|
||||
if matches!(tool.scope(), ToolScope::CliRpcOnly) {
|
||||
continue;
|
||||
}
|
||||
registry.replace_tool(Arc::new(RlmToolAdapter::new(
|
||||
registry.replace_tool(Arc::new(RhaiToolAdapter::new(
|
||||
parent.all_tools.clone(),
|
||||
tool.as_ref(),
|
||||
)));
|
||||
@@ -128,7 +132,7 @@ pub(super) fn build_capability_registry(parent: &ParentExecutionContext) -> Capa
|
||||
tools = tool_count,
|
||||
agents = agent_count,
|
||||
model = %parent.model_name,
|
||||
"[rlm] built capability registry"
|
||||
"[rhai_workflows] built capability registry"
|
||||
);
|
||||
registry
|
||||
}
|
||||
@@ -137,7 +141,7 @@ pub(super) fn build_capability_registry(parent: &ParentExecutionContext) -> Capa
|
||||
/// 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 {
|
||||
pub(super) struct RhaiToolAdapter {
|
||||
tools: Arc<Vec<Box<dyn OhTool>>>,
|
||||
name: String,
|
||||
description: String,
|
||||
@@ -145,7 +149,7 @@ pub(super) struct RlmToolAdapter {
|
||||
policy: TaToolPolicy,
|
||||
}
|
||||
|
||||
impl RlmToolAdapter {
|
||||
impl RhaiToolAdapter {
|
||||
fn new(tools: Arc<Vec<Box<dyn OhTool>>>, tool: &dyn OhTool) -> Self {
|
||||
let schema = TaToolSchema {
|
||||
name: tool.name().to_string(),
|
||||
@@ -171,7 +175,7 @@ impl RlmToolAdapter {
|
||||
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");
|
||||
tracing::warn!(tool = %self.name, "[rhai_workflows] bridged tool not found at call time");
|
||||
TaToolResult {
|
||||
call_id: call.id,
|
||||
name: call.name,
|
||||
@@ -186,7 +190,7 @@ impl RlmToolAdapter {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TaTool<()> for RlmToolAdapter {
|
||||
impl TaTool<()> for RhaiToolAdapter {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
@@ -229,12 +233,12 @@ async fn gated_execute(
|
||||
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");
|
||||
tracing::debug!(tool = %call.name, "[rhai_workflows] 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");
|
||||
tracing::info!(tool = %call.name, %reason, "[rhai_workflows] tool denied by approval gate");
|
||||
return TaToolResult {
|
||||
content: format!("Denied by approval gate: {reason}"),
|
||||
error: Some(format!("approval denied: {reason}")),
|
||||
@@ -309,7 +313,7 @@ impl HarnessAgent for SubagentCapability {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
tracing::debug!(agent = %self.agent_id, "[rlm] agent_query — spawning sub-agent");
|
||||
tracing::debug!(agent = %self.agent_id, "[rhai_workflows] agent_query — spawning sub-agent");
|
||||
let outcome = with_parent_context(
|
||||
self.parent.clone(),
|
||||
run_subagent(definition, &input.prompt, options),
|
||||
@@ -1,7 +1,7 @@
|
||||
//! `rlm` — language-based workflows over the TinyAgents Rhai `.ragsh` REPL.
|
||||
//! `rhai` — 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
|
||||
//! [`RhaiTool`], 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.
|
||||
@@ -17,4 +17,4 @@ mod types;
|
||||
|
||||
pub mod tools;
|
||||
|
||||
pub use tools::RlmTool;
|
||||
pub use tools::RhaiTool;
|
||||
@@ -17,9 +17,9 @@ 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};
|
||||
use super::policy::{resolve_policy, DEFAULT_RHAI_TIMEOUT_SECS};
|
||||
use super::sessions::RhaiSessionManager;
|
||||
use super::types::{RhaiCallSummary, RhaiEvalRequest, RhaiEvalResponse, RhaiLimitsRemaining};
|
||||
|
||||
/// Grace added to the inner policy timeout for the outer `spawn_blocking`
|
||||
/// backstop — the inner deadline should always fire first; this defends against
|
||||
@@ -28,12 +28,12 @@ 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;
|
||||
const MAX_RHAI_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`].
|
||||
/// A typed Rhai failure, each mapped to a model-consumable tool result by
|
||||
/// [`RhaiError::message`] (with a fix hint) and [`RhaiError::kind`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum RlmError {
|
||||
pub(crate) enum RhaiError {
|
||||
/// The autonomy tier or policy refused the session.
|
||||
Denied(String),
|
||||
/// Script failed to compile or raised a runtime error.
|
||||
@@ -56,60 +56,62 @@ pub(crate) enum RlmError {
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl RlmError {
|
||||
impl RhaiError {
|
||||
/// 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",
|
||||
RhaiError::Denied(_) => "denied",
|
||||
RhaiError::Script(_) => "script_error",
|
||||
RhaiError::Timeout(_) => "timeout",
|
||||
RhaiError::LimitExceeded(_) => "limit_exceeded",
|
||||
RhaiError::UnknownCapability(_) => "unknown_capability",
|
||||
RhaiError::Depth(_) => "recursion_depth",
|
||||
RhaiError::CapabilityError(_) => "capability_error",
|
||||
RhaiError::Cancelled => "cancelled",
|
||||
RhaiError::SessionBusy(_) => "session_busy",
|
||||
RhaiError::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.")
|
||||
RhaiError::Denied(m) => m.clone(),
|
||||
RhaiError::Script(m) => {
|
||||
format!("rhai_workflows 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.")
|
||||
RhaiError::Timeout(m) => {
|
||||
format!("rhai_workflows 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`.")
|
||||
RhaiError::LimitExceeded(m) => {
|
||||
format!("rhai_workflows 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.")
|
||||
RhaiError::UnknownCapability(m) => {
|
||||
format!("rhai_workflows unknown capability: {m}")
|
||||
}
|
||||
RlmError::Cancelled => {
|
||||
"rlm cell was cancelled by the user. The session is intact and resumable."
|
||||
RhaiError::Depth(m) => format!("rhai_workflows recursion depth exceeded: {m}"),
|
||||
RhaiError::CapabilityError(m) => {
|
||||
format!("rhai_workflows capability call failed: {m}\nInspect the failing call's arguments and retry.")
|
||||
}
|
||||
RhaiError::Cancelled => {
|
||||
"rhai_workflows 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}"),
|
||||
RhaiError::SessionBusy(m) => m.clone(),
|
||||
RhaiError::Internal(m) => format!("rhai_workflows 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 {
|
||||
struct RhaiAvailable {
|
||||
tools: Vec<String>,
|
||||
agents: Vec<String>,
|
||||
models: Vec<String>,
|
||||
}
|
||||
|
||||
impl RlmAvailable {
|
||||
impl RhaiAvailable {
|
||||
fn from_registry(registry: &CapabilityRegistry<()>) -> Self {
|
||||
Self {
|
||||
tools: registry.names(ComponentKind::Tool),
|
||||
@@ -130,17 +132,18 @@ enum CellRun {
|
||||
Poisoned,
|
||||
}
|
||||
|
||||
/// Evaluates one RLM cell against a (possibly new) session, fail-closed.
|
||||
/// Evaluates one Rhai 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> {
|
||||
/// backstop, and maps every outcome to [`RhaiEvalResponse`] or a typed
|
||||
/// [`RhaiError`].
|
||||
pub(crate) async fn eval_rhai_cell(req: RhaiEvalRequest) -> Result<RhaiEvalResponse, RhaiError> {
|
||||
let parent = current_parent().ok_or_else(|| {
|
||||
RlmError::Internal(
|
||||
"no parent execution context — the rlm tool must run inside an agent turn".to_string(),
|
||||
RhaiError::Internal(
|
||||
"no parent execution context — the rhai_workflows tool must run inside an agent turn"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -148,7 +151,7 @@ pub(crate) async fn eval_rlm_cell(req: RlmEvalRequest) -> Result<RlmEvalResponse
|
||||
.map(|p| p.autonomy)
|
||||
.unwrap_or(AutonomyLevel::Supervised);
|
||||
let policy =
|
||||
resolve_policy(tier, req.timeout_secs, req.limits.as_ref()).map_err(RlmError::Denied)?;
|
||||
resolve_policy(tier, req.timeout_secs, req.limits.as_ref()).map_err(RhaiError::Denied)?;
|
||||
|
||||
let registry = build_capability_registry(&parent);
|
||||
run_cell(
|
||||
@@ -156,35 +159,35 @@ pub(crate) async fn eval_rlm_cell(req: RlmEvalRequest) -> Result<RlmEvalResponse
|
||||
policy,
|
||||
registry,
|
||||
&parent.session_id,
|
||||
RlmSessionManager::global(),
|
||||
RhaiSessionManager::global(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The parent-independent core of [`eval_rlm_cell`]: given a resolved policy and
|
||||
/// The parent-independent core of [`eval_rhai_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,
|
||||
req: RhaiEvalRequest,
|
||||
policy: tinyagents::ReplPolicy,
|
||||
registry: CapabilityRegistry<()>,
|
||||
thread_scope: &str,
|
||||
manager: &RlmSessionManager,
|
||||
) -> Result<RlmEvalResponse, RlmError> {
|
||||
manager: &RhaiSessionManager,
|
||||
) -> Result<RhaiEvalResponse, RhaiError> {
|
||||
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);
|
||||
.unwrap_or_else(|| format!("rhai_workflows-{}", uuid::Uuid::new_v4()));
|
||||
let key = RhaiSessionManager::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(®istry);
|
||||
let available = RhaiAvailable::from_registry(®istry);
|
||||
let policy_for_build = policy.clone();
|
||||
|
||||
tracing::info!(
|
||||
@@ -192,7 +195,7 @@ async fn run_cell(
|
||||
thread_id = %thread_scope,
|
||||
session_id = %session_id,
|
||||
script_bytes = req.script.len(),
|
||||
"[rlm] eval_rlm_cell: start"
|
||||
"[rhai_workflows] eval_rhai_cell: start"
|
||||
);
|
||||
|
||||
let handle = manager.get_or_create(&key, move || {
|
||||
@@ -209,7 +212,9 @@ async fn run_cell(
|
||||
let flag = cell_flag.clone();
|
||||
tokio::spawn(async move {
|
||||
token.cancelled().await;
|
||||
tracing::debug!("[rlm] run cancellation observed — tripping cell cancel flag");
|
||||
tracing::debug!(
|
||||
"[rhai_workflows] run cancellation observed — tripping cell cancel flag"
|
||||
);
|
||||
flag.cancel();
|
||||
});
|
||||
}
|
||||
@@ -219,7 +224,7 @@ async fn run_cell(
|
||||
let flag_for_cell = cell_flag.clone();
|
||||
let outer_bound = policy
|
||||
.timeout
|
||||
.unwrap_or(Duration::from_secs(DEFAULT_RLM_TIMEOUT_SECS))
|
||||
.unwrap_or(Duration::from_secs(DEFAULT_RHAI_TIMEOUT_SECS))
|
||||
+ OUTER_TIMEOUT_GRACE;
|
||||
|
||||
let join = tokio::task::spawn_blocking(move || {
|
||||
@@ -238,9 +243,9 @@ async fn run_cell(
|
||||
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}"
|
||||
tracing::error!(session_key = %key, %join_err, "[rhai_workflows] cell task panicked — session dropped");
|
||||
return Err(RhaiError::Internal(format!(
|
||||
"rhai_workflows cell task failed: {join_err}"
|
||||
)));
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
@@ -250,10 +255,10 @@ async fn run_cell(
|
||||
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)"
|
||||
"[rhai_workflows] 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(),
|
||||
return Err(RhaiError::Timeout(
|
||||
"the rhai_workflows cell exceeded its outer wall-clock backstop".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
@@ -261,15 +266,15 @@ async fn run_cell(
|
||||
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"
|
||||
tracing::info!(session_key = %key, "[rhai_workflows] session busy — concurrent cell rejected");
|
||||
return Err(RhaiError::SessionBusy(format!(
|
||||
"rhai_workflows 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(),
|
||||
return Err(RhaiError::Internal(
|
||||
"the rhai_workflows session was poisoned by a prior panic and has been dropped; start a fresh session".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
@@ -278,7 +283,7 @@ async fn run_cell(
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
let mapped = map_eval_error(err, &available);
|
||||
tracing::info!(session_key = %key, kind = mapped.kind(), "[rlm] cell failed");
|
||||
tracing::info!(session_key = %key, kind = mapped.kind(), "[rhai_workflows] cell failed");
|
||||
return Err(mapped);
|
||||
}
|
||||
};
|
||||
@@ -288,16 +293,16 @@ async fn run_cell(
|
||||
manager.close(&key);
|
||||
}
|
||||
|
||||
let response = RlmEvalResponse {
|
||||
let response = RhaiEvalResponse {
|
||||
session_id,
|
||||
stdout: truncate_chars(&result.stdout, MAX_RLM_RESULT_CHARS),
|
||||
stdout: truncate_chars(&result.stdout, MAX_RHAI_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 {
|
||||
limits_remaining: RhaiLimitsRemaining {
|
||||
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),
|
||||
@@ -310,45 +315,45 @@ async fn run_cell(
|
||||
elapsed_ms = response.elapsed_ms,
|
||||
calls = response.calls.len(),
|
||||
cells_used = response.cells_used,
|
||||
"[rlm] eval_rlm_cell: done"
|
||||
"[rhai_workflows] eval_rhai_cell: done"
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Maps a raw `TinyAgentsError` from `eval_cell` onto a typed [`RlmError`],
|
||||
/// Maps a raw `TinyAgentsError` from `eval_cell` onto a typed [`RhaiError`],
|
||||
/// enriching unknown-capability errors with the live registered names.
|
||||
fn map_eval_error(err: TinyAgentsError, available: &RlmAvailable) -> RlmError {
|
||||
fn map_eval_error(err: TinyAgentsError, available: &RhaiAvailable) -> RhaiError {
|
||||
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!(
|
||||
TinyAgentsError::Validation(m) => RhaiError::Script(m),
|
||||
TinyAgentsError::Timeout(m) => RhaiError::Timeout(m),
|
||||
TinyAgentsError::LimitExceeded(m) => RhaiError::LimitExceeded(m),
|
||||
TinyAgentsError::Cancelled => RhaiError::Cancelled,
|
||||
TinyAgentsError::SubAgentDepth(n) => RhaiError::Depth(format!(
|
||||
"sub-agent recursion exceeded the maximum depth of {n}"
|
||||
)),
|
||||
TinyAgentsError::ModelNotFound(name) => RlmError::UnknownCapability(format!(
|
||||
TinyAgentsError::ModelNotFound(name) => RhaiError::UnknownCapability(format!(
|
||||
"model `{name}` is not registered. Available models: {}",
|
||||
join_or_none(&available.models)
|
||||
)),
|
||||
TinyAgentsError::ToolNotFound(name) => RlmError::UnknownCapability(format!(
|
||||
TinyAgentsError::ToolNotFound(name) => RhaiError::UnknownCapability(format!(
|
||||
"tool `{name}` is not registered. Available tools: {}",
|
||||
join_or_none(&available.tools)
|
||||
)),
|
||||
TinyAgentsError::Capability(m) => RlmError::UnknownCapability(format!(
|
||||
TinyAgentsError::Capability(m) => RhaiError::UnknownCapability(format!(
|
||||
"{m}. Available agents: {}",
|
||||
join_or_none(&available.agents)
|
||||
)),
|
||||
TinyAgentsError::Tool(m) => RlmError::CapabilityError(m),
|
||||
other => RlmError::Script(other.to_string()),
|
||||
TinyAgentsError::Tool(m) => RhaiError::CapabilityError(m),
|
||||
other => RhaiError::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> {
|
||||
fn summarize_calls(calls: &[ReplCallRecord]) -> Vec<RhaiCallSummary> {
|
||||
calls
|
||||
.iter()
|
||||
.map(|c| RlmCallSummary {
|
||||
.map(|c| RhaiCallSummary {
|
||||
kind: call_kind_str(c.kind).to_string(),
|
||||
name: c.name.clone(),
|
||||
elapsed_ms: c.elapsed.as_millis() as u64,
|
||||
@@ -383,7 +388,7 @@ fn truncate_chars(s: &str, max: usize) -> String {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out: String = s.chars().take(max).collect();
|
||||
out.push_str("\n… (rlm output truncated)");
|
||||
out.push_str("\n… (rhai_workflows output truncated)");
|
||||
out
|
||||
}
|
||||
|
||||
@@ -410,16 +415,16 @@ mod tests {
|
||||
|
||||
#[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())
|
||||
assert_eq!(RhaiError::Cancelled.kind(), "cancelled");
|
||||
assert_eq!(RhaiError::Timeout("x".into()).kind(), "timeout");
|
||||
assert!(RhaiError::Script("boom".into())
|
||||
.message()
|
||||
.contains("Fix the script"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_taxonomy_variant_maps_to_the_right_kind() {
|
||||
let avail = RlmAvailable {
|
||||
let avail = RhaiAvailable {
|
||||
tools: vec!["read_file".into(), "grep".into()],
|
||||
agents: vec!["researcher".into()],
|
||||
models: vec!["chat".into()],
|
||||
@@ -494,8 +499,8 @@ mod tests {
|
||||
registry
|
||||
}
|
||||
|
||||
fn req(script: &str) -> RlmEvalRequest {
|
||||
RlmEvalRequest {
|
||||
fn req(script: &str) -> RhaiEvalRequest {
|
||||
RhaiEvalRequest {
|
||||
script: script.to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
@@ -503,7 +508,7 @@ mod tests {
|
||||
|
||||
#[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 manager = RhaiSessionManager::new_for_test();
|
||||
let resp = run_cell(
|
||||
req(r#"tool_call(#{ tool: "echo", arguments: #{ msg: "hi" } })"#),
|
||||
ReplPolicy::default(),
|
||||
@@ -526,7 +531,7 @@ mod tests {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn parse_error_is_a_script_error() {
|
||||
let manager = RlmSessionManager::new_for_test();
|
||||
let manager = RhaiSessionManager::new_for_test();
|
||||
let err = run_cell(
|
||||
req("let x = ;"),
|
||||
ReplPolicy::default(),
|
||||
@@ -541,7 +546,7 @@ mod tests {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unknown_tool_lists_registered_names() {
|
||||
let manager = RlmSessionManager::new_for_test();
|
||||
let manager = RhaiSessionManager::new_for_test();
|
||||
let err = run_cell(
|
||||
req(r#"tool_call(#{ tool: "does_not_exist" })"#),
|
||||
ReplPolicy::default(),
|
||||
@@ -557,7 +562,7 @@ mod tests {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn call_count_limit_fails_closed() {
|
||||
let manager = RlmSessionManager::new_for_test();
|
||||
let manager = RhaiSessionManager::new_for_test();
|
||||
let policy = ReplPolicy {
|
||||
max_tool_calls: 1,
|
||||
..ReplPolicy::default()
|
||||
@@ -576,7 +581,7 @@ mod tests {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn script_loop_times_out_within_the_bound() {
|
||||
let manager = RlmSessionManager::new_for_test();
|
||||
let manager = RhaiSessionManager::new_for_test();
|
||||
let policy = ReplPolicy {
|
||||
timeout: Some(Duration::from_millis(200)),
|
||||
max_operations: 0,
|
||||
@@ -596,7 +601,7 @@ mod tests {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn oversized_output_fails_closed() {
|
||||
let manager = RlmSessionManager::new_for_test();
|
||||
let manager = RhaiSessionManager::new_for_test();
|
||||
let policy = ReplPolicy {
|
||||
max_output_bytes: 64,
|
||||
..ReplPolicy::default()
|
||||
@@ -615,9 +620,9 @@ mod tests {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn namespace_persists_and_close_session_drops_it() {
|
||||
let manager = RlmSessionManager::new_for_test();
|
||||
let manager = RhaiSessionManager::new_for_test();
|
||||
let first = run_cell(
|
||||
RlmEvalRequest {
|
||||
RhaiEvalRequest {
|
||||
script: "let acc = 10; acc".into(),
|
||||
session_id: Some("keep".into()),
|
||||
..Default::default()
|
||||
@@ -632,7 +637,7 @@ mod tests {
|
||||
assert_eq!(first.session_id, "keep");
|
||||
|
||||
let second = run_cell(
|
||||
RlmEvalRequest {
|
||||
RhaiEvalRequest {
|
||||
script: "acc + 5".into(),
|
||||
session_id: Some("keep".into()),
|
||||
close_session: true,
|
||||
@@ -652,14 +657,14 @@ mod tests {
|
||||
|
||||
#[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 manager = RhaiSessionManager::new_for_test();
|
||||
let key = RhaiSessionManager::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 {
|
||||
RhaiEvalRequest {
|
||||
script: "1".into(),
|
||||
session_id: Some("busy".into()),
|
||||
..Default::default()
|
||||
@@ -670,6 +675,6 @@ mod tests {
|
||||
&manager,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(res, Err(RlmError::SessionBusy(_))), "got {res:?}");
|
||||
assert!(matches!(res, Err(RhaiError::SessionBusy(_))), "got {res:?}");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! 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
|
||||
//! Every Rhai 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
|
||||
@@ -14,38 +14,38 @@ use tinyagents::ReplPolicy;
|
||||
use crate::openhuman::security::policy::AutonomyLevel;
|
||||
use crate::openhuman::tool_timeout;
|
||||
|
||||
use super::types::RlmLimitsOverride;
|
||||
use super::types::RhaiLimitsOverride;
|
||||
|
||||
/// 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
|
||||
/// Matches the `rhai_workflows` tool's default `ToolTimeout::Secs(300)` so the inner
|
||||
/// deadline and the harness backstop agree.
|
||||
pub const DEFAULT_RLM_TIMEOUT_SECS: u64 = 300;
|
||||
pub const DEFAULT_RHAI_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
/// Hard upper bound on batched concurrency, regardless of tier or override.
|
||||
pub const MAX_RLM_CONCURRENCY: usize = 8;
|
||||
pub const MAX_RHAI_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
|
||||
/// Maximum sub-agent recursion depth an Rhai 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;
|
||||
pub const RHAI_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.
|
||||
/// Rhai entirely (`readonly`). The `reason` is surfaced verbatim to the model.
|
||||
pub fn resolve_policy(
|
||||
tier: AutonomyLevel,
|
||||
timeout_secs: Option<u64>,
|
||||
overrides: Option<&RlmLimitsOverride>,
|
||||
overrides: Option<&RhaiLimitsOverride>,
|
||||
) -> Result<ReplPolicy, String> {
|
||||
if tier == AutonomyLevel::ReadOnly {
|
||||
return Err(
|
||||
"the `rlm` tool is disabled at the read-only autonomy tier (it can drive tools, \
|
||||
"the `rhai_workflows` 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(),
|
||||
);
|
||||
@@ -55,15 +55,15 @@ pub fn resolve_policy(
|
||||
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.
|
||||
// defaulting to DEFAULT_RHAI_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);
|
||||
.unwrap_or(DEFAULT_RHAI_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_depth: base.max_depth.min(RHAI_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),
|
||||
@@ -79,7 +79,7 @@ pub fn resolve_policy(
|
||||
max_tool_calls = policy.max_tool_calls,
|
||||
max_concurrency = policy.max_concurrency,
|
||||
max_depth = policy.max_depth,
|
||||
"[rlm] resolved ReplPolicy"
|
||||
"[rhai_workflows] resolved ReplPolicy"
|
||||
);
|
||||
Ok(policy)
|
||||
}
|
||||
@@ -103,16 +103,16 @@ fn clamp_limit(default: usize, requested: Option<usize>, allow_raise: bool) -> u
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamps batched concurrency to `[1, MAX_RLM_CONCURRENCY]`, and to the default
|
||||
/// Clamps batched concurrency to `[1, MAX_RHAI_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
|
||||
MAX_RHAI_CONCURRENCY
|
||||
} else {
|
||||
default.min(MAX_RLM_CONCURRENCY)
|
||||
default.min(MAX_RHAI_CONCURRENCY)
|
||||
};
|
||||
match requested {
|
||||
None => default.min(MAX_RLM_CONCURRENCY),
|
||||
None => default.min(MAX_RHAI_CONCURRENCY),
|
||||
Some(n) => n.clamp(1, ceiling.max(1)),
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ mod tests {
|
||||
let p = resolve_policy(AutonomyLevel::Supervised, None, None).expect("policy");
|
||||
assert_eq!(
|
||||
p.timeout,
|
||||
Some(Duration::from_secs(DEFAULT_RLM_TIMEOUT_SECS))
|
||||
Some(Duration::from_secs(DEFAULT_RHAI_TIMEOUT_SECS))
|
||||
);
|
||||
|
||||
// A caller request is clamped to [1, 3600].
|
||||
@@ -145,14 +145,14 @@ mod tests {
|
||||
let p = resolve_policy(AutonomyLevel::Supervised, Some(0), None).expect("policy");
|
||||
assert_eq!(
|
||||
p.timeout,
|
||||
Some(Duration::from_secs(DEFAULT_RLM_TIMEOUT_SECS))
|
||||
Some(Duration::from_secs(DEFAULT_RHAI_TIMEOUT_SECS))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supervised_may_only_tighten_limits() {
|
||||
let base = ReplPolicy::default();
|
||||
let ov = RlmLimitsOverride {
|
||||
let ov = RhaiLimitsOverride {
|
||||
max_tool_calls: Some(base.max_tool_calls * 10), // request a raise
|
||||
..Default::default()
|
||||
};
|
||||
@@ -162,7 +162,7 @@ mod tests {
|
||||
"supervised cannot raise above the default"
|
||||
);
|
||||
|
||||
let ov = RlmLimitsOverride {
|
||||
let ov = RhaiLimitsOverride {
|
||||
max_tool_calls: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -173,7 +173,7 @@ mod tests {
|
||||
#[test]
|
||||
fn full_may_raise_up_to_the_ceiling() {
|
||||
let base = ReplPolicy::default();
|
||||
let ov = RlmLimitsOverride {
|
||||
let ov = RhaiLimitsOverride {
|
||||
max_model_calls: Some(base.max_model_calls * 100), // way over ceiling
|
||||
..Default::default()
|
||||
};
|
||||
@@ -187,17 +187,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn concurrency_is_hard_capped() {
|
||||
let ov = RlmLimitsOverride {
|
||||
let ov = RhaiLimitsOverride {
|
||||
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);
|
||||
assert_eq!(p.max_concurrency, MAX_RHAI_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);
|
||||
assert!(p.max_depth <= RHAI_MAX_DEPTH);
|
||||
}
|
||||
}
|
||||
@@ -60,16 +60,16 @@ pub(super) struct CellStats {
|
||||
}
|
||||
|
||||
/// The process-global session manager.
|
||||
pub(super) struct RlmSessionManager {
|
||||
pub(super) struct RhaiSessionManager {
|
||||
inner: Mutex<HashMap<String, SessionSlot>>,
|
||||
}
|
||||
|
||||
static MANAGER: OnceLock<RlmSessionManager> = OnceLock::new();
|
||||
static MANAGER: OnceLock<RhaiSessionManager> = OnceLock::new();
|
||||
|
||||
impl RlmSessionManager {
|
||||
impl RhaiSessionManager {
|
||||
/// Returns the process-global manager, initialising it on first use.
|
||||
pub(super) fn global() -> &'static RlmSessionManager {
|
||||
MANAGER.get_or_init(|| RlmSessionManager {
|
||||
pub(super) fn global() -> &'static RhaiSessionManager {
|
||||
MANAGER.get_or_init(|| RhaiSessionManager {
|
||||
inner: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
@@ -97,7 +97,10 @@ impl RlmSessionManager {
|
||||
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");
|
||||
tracing::debug!(
|
||||
session_key = key,
|
||||
"[rhai_workflows] reusing existing session"
|
||||
);
|
||||
return SlotHandle {
|
||||
session: slot.session.clone(),
|
||||
cancel: slot.cancel.clone(),
|
||||
@@ -122,7 +125,7 @@ impl RlmSessionManager {
|
||||
tracing::debug!(
|
||||
session_key = key,
|
||||
live_sessions = map.len(),
|
||||
"[rlm] created new session"
|
||||
"[rhai_workflows] created new session"
|
||||
);
|
||||
SlotHandle {
|
||||
session,
|
||||
@@ -159,7 +162,7 @@ impl RlmSessionManager {
|
||||
/// 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");
|
||||
tracing::debug!(session_key = key, "[rhai_workflows] closed session");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +174,7 @@ impl RlmSessionManager {
|
||||
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)");
|
||||
tracing::debug!(session_key = %k, "[rhai_workflows] evicting idle session (TTL)");
|
||||
}
|
||||
keep
|
||||
});
|
||||
@@ -185,7 +188,7 @@ impl RlmSessionManager {
|
||||
.map(|(k, _)| k.clone())
|
||||
{
|
||||
map.remove(&lru_key);
|
||||
tracing::debug!(session_key = %lru_key, "[rlm] evicting LRU session (cap)");
|
||||
tracing::debug!(session_key = %lru_key, "[rhai_workflows] evicting LRU session (cap)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +202,7 @@ impl RlmSessionManager {
|
||||
/// the process-global singleton and from each other.
|
||||
#[cfg(test)]
|
||||
pub(super) fn new_for_test() -> Self {
|
||||
RlmSessionManager {
|
||||
RhaiSessionManager {
|
||||
inner: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
@@ -216,8 +219,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn namespace_persists_across_cells_in_one_session() {
|
||||
let manager = RlmSessionManager::new_for_test();
|
||||
let key = RlmSessionManager::session_key("t", "s1");
|
||||
let manager = RhaiSessionManager::new_for_test();
|
||||
let key = RhaiSessionManager::session_key("t", "s1");
|
||||
|
||||
let handle = manager.get_or_create(&key, build_session);
|
||||
assert!(handle.fresh);
|
||||
@@ -243,20 +246,20 @@ mod tests {
|
||||
|
||||
#[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);
|
||||
let manager = RhaiSessionManager::new_for_test();
|
||||
let a = manager.get_or_create(&RhaiSessionManager::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);
|
||||
let b = manager.get_or_create(&RhaiSessionManager::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");
|
||||
let manager = RhaiSessionManager::new_for_test();
|
||||
let k1 = RhaiSessionManager::session_key("thread-1", "shared");
|
||||
let k2 = RhaiSessionManager::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);
|
||||
@@ -268,10 +271,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn lru_cap_bounds_the_number_of_live_sessions() {
|
||||
let manager = RlmSessionManager::new_for_test();
|
||||
let manager = RhaiSessionManager::new_for_test();
|
||||
for i in 0..(MAX_SESSIONS + 5) {
|
||||
manager.get_or_create(
|
||||
&RlmSessionManager::session_key("t", &format!("s{i}")),
|
||||
&RhaiSessionManager::session_key("t", &format!("s{i}")),
|
||||
build_session,
|
||||
);
|
||||
}
|
||||
@@ -284,8 +287,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn close_drops_the_session() {
|
||||
let manager = RlmSessionManager::new_for_test();
|
||||
let key = RlmSessionManager::session_key("t", "closeme");
|
||||
let manager = RhaiSessionManager::new_for_test();
|
||||
let key = RhaiSessionManager::session_key("t", "closeme");
|
||||
manager.get_or_create(&key, build_session);
|
||||
assert_eq!(manager.len(), 1);
|
||||
manager.close(&key);
|
||||
@@ -296,8 +299,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn finish_cell_accumulates_and_bounds_are_reported() {
|
||||
let manager = RlmSessionManager::new_for_test();
|
||||
let key = RlmSessionManager::session_key("t", "stats");
|
||||
let manager = RhaiSessionManager::new_for_test();
|
||||
let key = RhaiSessionManager::session_key("t", "stats");
|
||||
let policy = ReplPolicy::default();
|
||||
let handle = manager.get_or_create(&key, || {
|
||||
ReplSession::<()>::new().with_policy(policy.clone())
|
||||
@@ -1,39 +1,39 @@
|
||||
//! The first-class `rlm` tool — the orchestrator's language-workflow surface.
|
||||
//! The first-class `rhai_workflows` 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.
|
||||
//! [`super::bridge`]), so the `rhai_workflows` 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;
|
||||
use super::policy::DEFAULT_RHAI_TIMEOUT_SECS;
|
||||
use super::types::RhaiEvalRequest;
|
||||
|
||||
/// The `rlm` language-workflow tool. Stateless: it resolves the parent turn
|
||||
/// The `rhai_workflows` language-workflow tool. Stateless: it resolves the parent turn
|
||||
/// context, autonomy tier, and cancellation per call.
|
||||
pub struct RlmTool;
|
||||
pub struct RhaiTool;
|
||||
|
||||
impl RlmTool {
|
||||
impl RhaiTool {
|
||||
/// Builds the tool.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RlmTool {
|
||||
impl Default for RhaiTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for RlmTool {
|
||||
impl Tool for RhaiTool {
|
||||
fn name(&self) -> &str {
|
||||
"rlm"
|
||||
"rhai_workflows"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
@@ -52,7 +52,7 @@ In-cell built-ins:\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\
|
||||
fix and retry in the same session. Excluded from cells: `rhai_workflows` itself, `spawn_*`, and workflow tools.\n\
|
||||
\n\
|
||||
Example — parallel fan-out over sub-agents, then reduce:\n\
|
||||
```\n\
|
||||
@@ -71,7 +71,7 @@ for r in reads { if r.ok && r.content.contains(\"TODO\") { hits.push(r.content);
|
||||
hits\n\
|
||||
```\n\
|
||||
\n\
|
||||
Prefer `rlm` over spawn_parallel_agents when you need loops, conditionals, or a dedup/verify \
|
||||
Prefer `rhai_workflows` 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."
|
||||
}
|
||||
@@ -86,7 +86,7 @@ pipeline over results. Pass a `session_id` to continue a prior cell's bindings;
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"description": "Continue a prior RLM session's namespace; omit for a fresh session (its generated id is returned)."
|
||||
"description": "Continue a prior Rhai session's namespace; omit for a fresh session (its generated id is returned)."
|
||||
},
|
||||
"timeout_secs": {
|
||||
"type": "integer",
|
||||
@@ -132,12 +132,12 @@ pipeline over results. Pass a `session_id` to continue a prior cell's bindings;
|
||||
requested,
|
||||
crate::openhuman::tool_timeout::MAX_TIMEOUT_SECS,
|
||||
)
|
||||
.unwrap_or(DEFAULT_RLM_TIMEOUT_SECS);
|
||||
.unwrap_or(DEFAULT_RHAI_TIMEOUT_SECS);
|
||||
ToolTimeout::Secs(secs)
|
||||
}
|
||||
|
||||
fn display_label(&self, _args: &Value) -> Option<String> {
|
||||
Some("running RLM workflow".to_string())
|
||||
Some("running Rhai workflow".to_string())
|
||||
}
|
||||
|
||||
fn display_detail(&self, args: &Value) -> Option<String> {
|
||||
@@ -155,30 +155,33 @@ pipeline over results. Pass a `session_id` to continue a prior cell's bindings;
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
let req: RlmEvalRequest = match serde_json::from_value(args) {
|
||||
let req: RhaiEvalRequest = match serde_json::from_value(args) {
|
||||
Ok(req) => req,
|
||||
Err(err) => {
|
||||
return Ok(ToolResult::error(format!(
|
||||
"rlm: invalid arguments: {err}. Required: `script` (string). \
|
||||
"rhai_workflows: invalid arguments: {err}. Required: `script` (string). \
|
||||
Optional: session_id, timeout_secs (1–3600), 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.",
|
||||
"rhai_workflows: `script` is required and must be a non-empty Rhai cell.",
|
||||
));
|
||||
}
|
||||
|
||||
match super::ops::eval_rlm_cell(req).await {
|
||||
match super::ops::eval_rhai_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}"
|
||||
"rhai_workflows: internal error rendering result: {err}"
|
||||
))),
|
||||
},
|
||||
Err(err) => {
|
||||
log::info!("[rlm] tool returning error result (kind={})", err.kind());
|
||||
log::info!(
|
||||
"[rhai_workflows] tool returning error result (kind={})",
|
||||
err.kind()
|
||||
);
|
||||
Ok(ToolResult::error(err.message()))
|
||||
}
|
||||
}
|
||||
@@ -191,8 +194,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn metadata_is_stable() {
|
||||
let tool = RlmTool::new();
|
||||
assert_eq!(tool.name(), "rlm");
|
||||
let tool = RhaiTool::new();
|
||||
assert_eq!(tool.name(), "rhai_workflows");
|
||||
assert_eq!(tool.permission_level(), PermissionLevel::Execute);
|
||||
assert!(matches!(tool.scope(), ToolScope::AgentOnly));
|
||||
assert!(!tool.external_effect());
|
||||
@@ -200,10 +203,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn timeout_is_always_bounded() {
|
||||
let tool = RlmTool::new();
|
||||
let tool = RhaiTool::new();
|
||||
assert_eq!(
|
||||
tool.timeout_policy(&json!({})),
|
||||
ToolTimeout::Secs(DEFAULT_RLM_TIMEOUT_SECS)
|
||||
ToolTimeout::Secs(DEFAULT_RHAI_TIMEOUT_SECS)
|
||||
);
|
||||
assert_eq!(
|
||||
tool.timeout_policy(&json!({ "timeout_secs": 42 })),
|
||||
@@ -218,7 +221,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn display_detail_elides_first_script_line() {
|
||||
let tool = RlmTool::new();
|
||||
let tool = RhaiTool::new();
|
||||
let detail = tool
|
||||
.display_detail(&json!({ "script": "\n\nlet x = 1;\nlet y = 2;" }))
|
||||
.expect("detail");
|
||||
@@ -227,7 +230,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_script_is_a_model_consumable_error() {
|
||||
let tool = RlmTool::new();
|
||||
let tool = RhaiTool::new();
|
||||
let result = tool
|
||||
.execute(json!({ "script": " " }))
|
||||
.await
|
||||
@@ -237,7 +240,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_script_is_a_model_consumable_error() {
|
||||
let tool = RlmTool::new();
|
||||
let tool = RhaiTool::new();
|
||||
// No `script` field at all → invalid arguments, not a panic.
|
||||
let result = tool
|
||||
.execute(json!({ "session_id": "x" }))
|
||||
@@ -249,7 +252,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn schema_requires_script() {
|
||||
let schema = RlmTool::new().parameters_schema();
|
||||
let schema = RhaiTool::new().parameters_schema();
|
||||
assert_eq!(schema["required"], json!(["script"]));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Serde domain types for the `rlm` language-workflow tool.
|
||||
//! Serde domain types for the `rhai` language-workflow tool.
|
||||
//!
|
||||
//! These cross the tool boundary (parsed from the `rlm` tool call, and rendered
|
||||
//! These cross the tool boundary (parsed from the `rhai_workflows` 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
|
||||
@@ -8,32 +8,32 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A caller-supplied RLM session id. Continuing a prior `session_id` reuses that
|
||||
/// A caller-supplied Rhai 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);
|
||||
pub struct RhaiSessionId(pub String);
|
||||
|
||||
impl RlmSessionId {
|
||||
impl RhaiSessionId {
|
||||
/// The session id as a string slice.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RlmSessionId {
|
||||
impl std::fmt::Display for RhaiSessionId {
|
||||
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`
|
||||
/// Per-call limit overrides a caller may pass in the `rhai_workflows` 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 {
|
||||
pub struct RhaiLimitsOverride {
|
||||
/// Requested `max_tool_calls` for the session.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_tool_calls: Option<usize>,
|
||||
@@ -48,9 +48,9 @@ pub struct RlmLimitsOverride {
|
||||
pub max_concurrency: Option<usize>,
|
||||
}
|
||||
|
||||
/// A parsed `rlm` tool call — one cell to evaluate against a session.
|
||||
/// A parsed `rhai_workflows` tool call — one cell to evaluate against a session.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct RlmEvalRequest {
|
||||
pub struct RhaiEvalRequest {
|
||||
/// The Rhai workflow cell to evaluate.
|
||||
pub script: String,
|
||||
/// Continue a prior session's namespace; `None` starts a fresh session.
|
||||
@@ -61,7 +61,7 @@ pub struct RlmEvalRequest {
|
||||
pub timeout_secs: Option<u64>,
|
||||
/// Optional per-session limit overrides.
|
||||
#[serde(default)]
|
||||
pub limits: Option<RlmLimitsOverride>,
|
||||
pub limits: Option<RhaiLimitsOverride>,
|
||||
/// Close (drop) the session after this cell.
|
||||
#[serde(default)]
|
||||
pub close_session: bool,
|
||||
@@ -72,7 +72,7 @@ pub struct RlmEvalRequest {
|
||||
/// 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 {
|
||||
pub struct RhaiCallSummary {
|
||||
/// `model` | `tool` | `agent` | `graph` | `emit`.
|
||||
pub kind: String,
|
||||
/// The capability or event name.
|
||||
@@ -87,7 +87,7 @@ pub struct RlmCallSummary {
|
||||
/// 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 {
|
||||
pub struct RhaiLimitsRemaining {
|
||||
/// Cells left before `max_iterations`.
|
||||
pub cells: usize,
|
||||
/// `model_query` calls left.
|
||||
@@ -98,10 +98,10 @@ pub struct RlmLimitsRemaining {
|
||||
pub agent_calls: usize,
|
||||
}
|
||||
|
||||
/// The structured result of evaluating one RLM cell, rendered into the JSON
|
||||
/// The structured result of evaluating one Rhai cell, rendered into the JSON
|
||||
/// content of the tool result.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct RlmEvalResponse {
|
||||
pub struct RhaiEvalResponse {
|
||||
/// 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,
|
||||
@@ -113,7 +113,7 @@ pub struct RlmEvalResponse {
|
||||
/// Names of persistent variables the cell created or changed.
|
||||
pub variables_changed: Vec<String>,
|
||||
/// Summarized capability calls the cell performed.
|
||||
pub calls: Vec<RlmCallSummary>,
|
||||
pub calls: Vec<RhaiCallSummary>,
|
||||
/// The final answer, if the cell called `answer(...)`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub final_answer: Option<String>,
|
||||
@@ -122,7 +122,7 @@ pub struct RlmEvalResponse {
|
||||
/// Number of cells this session has evaluated so far.
|
||||
pub cells_used: usize,
|
||||
/// Remaining per-session budget.
|
||||
pub limits_remaining: RlmLimitsRemaining,
|
||||
pub limits_remaining: RhaiLimitsRemaining,
|
||||
/// Whether the session was closed after this cell.
|
||||
#[serde(default)]
|
||||
pub closed: bool,
|
||||
@@ -366,7 +366,7 @@ pub(super) struct ProviderModel {
|
||||
/// 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
|
||||
/// `.ragsh` REPL bridge (`crate::openhuman::rhai_workflows`). [`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>,
|
||||
|
||||
@@ -38,7 +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::rhai_workflows::tools::*;
|
||||
pub use crate::openhuman::screen_intelligence::tools::*;
|
||||
pub use crate::openhuman::search::tools::*;
|
||||
pub use crate::openhuman::security::tools::*;
|
||||
|
||||
@@ -966,26 +966,28 @@ 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
|
||||
// Language-workflow `rhai_workflows` tool (`.ragsh` REPL, `openhuman::rhai_workflows`): 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
|
||||
// and behind the `OPENHUMAN_RHAI_WORKFLOWS=0` kill switch. Every effectful inner call
|
||||
// still re-gates itself in the Rhai bridge, so this surface adds no new
|
||||
// ungated capability.
|
||||
let rlm_enabled = std::env::var("OPENHUMAN_RLM")
|
||||
let rhai_workflows_enabled = std::env::var("OPENHUMAN_RHAI_WORKFLOWS")
|
||||
.or_else(|_| std::env::var("OPENHUMAN_RHAI"))
|
||||
.or_else(|_| std::env::var("OPENHUMAN_RLM"))
|
||||
.map(|v| v != "0")
|
||||
.unwrap_or(true);
|
||||
if rlm_enabled
|
||||
if rhai_workflows_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");
|
||||
tools.push(Box::new(crate::openhuman::rhai_workflows::RhaiTool::new()));
|
||||
tracing::debug!("[rhai_workflows] registered rhai_workflows language-workflow tool");
|
||||
} else {
|
||||
tracing::debug!(
|
||||
rlm_enabled,
|
||||
rhai_workflows_enabled,
|
||||
tier = ?security.autonomy,
|
||||
"[rlm] rlm tool not registered (readonly tier or OPENHUMAN_RLM=0)"
|
||||
"[rhai_workflows] rhai_workflows tool not registered (readonly tier or OPENHUMAN_RHAI_WORKFLOWS=0)"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user