use std::sync::{Arc, Mutex};
use std::time::Instant;
use async_trait::async_trait;
use rhai::{Array, Dynamic, Engine, EvalAltResult, Map, Position, Scope};
use serde_json::Value;
use super::{CellEval, RlmInterpreter, bridge_block_on};
use crate::error::{Result, TinyAgentsError};
use crate::rlm::host::{RlmHostApi, is_fatal};
use crate::rlm::types::HostCall;
const DEADLINE_TOKEN: &str = "rlm cell exceeded its wall-clock timeout";
const CANCELLED_TOKEN: &str = "rlm cell cancelled by host";
pub struct RhaiInterpreter {
max_operations: u64,
scope: Scope<'static>,
}
impl RhaiInterpreter {
pub fn new(max_operations: u64) -> Self {
Self {
max_operations,
scope: Scope::new(),
}
}
}
#[derive(Default)]
struct CellState {
stdout: String,
fatal: Option<TinyAgentsError>,
}
type SharedCellState = Arc<Mutex<CellState>>;
fn dispatch(
host: &Arc<dyn RlmHostApi>,
cell: &SharedCellState,
call: HostCall,
) -> std::result::Result<Value, Box<EvalAltResult>> {
let deadline = host.deadline();
let cancel = host.cancel_flag();
match bridge_block_on(deadline, &cancel, host.handle(call)) {
Ok(value) => Ok(value),
Err(err) => {
let message = err.to_string();
if is_fatal(&err) {
cell.lock().expect("cell state poisoned").fatal = Some(err);
}
Err(Box::new(EvalAltResult::ErrorRuntime(
Dynamic::from(message),
Position::NONE,
)))
}
}
}
pub(crate) fn dynamic_to_json(value: &Dynamic) -> Value {
if value.is_unit() {
Value::Null
} else if let Ok(b) = value.as_bool() {
Value::Bool(b)
} else if let Ok(i) = value.as_int() {
Value::from(i)
} else if let Ok(f) = value.as_float() {
serde_json::Number::from_f64(f)
.map(Value::Number)
.unwrap_or(Value::Null)
} else if let Some(s) = value.read_lock::<rhai::ImmutableString>() {
Value::String(s.to_string())
} else if let Some(array) = value.read_lock::<Array>() {
Value::Array(array.iter().map(dynamic_to_json).collect())
} else if let Some(map) = value.read_lock::<Map>() {
Value::Object(
map.iter()
.map(|(k, v)| (k.to_string(), dynamic_to_json(v)))
.collect(),
)
} else {
Value::String(value.to_string())
}
}
pub(crate) fn json_to_dynamic(value: &Value) -> Dynamic {
match value {
Value::Null => Dynamic::UNIT,
Value::Bool(b) => Dynamic::from(*b),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
Dynamic::from(i)
} else {
Dynamic::from(n.as_f64().unwrap_or(0.0))
}
}
Value::String(s) => Dynamic::from(s.clone()),
Value::Array(items) => {
Dynamic::from_array(items.iter().map(json_to_dynamic).collect::<Array>())
}
Value::Object(map) => {
let mut out = Map::new();
for (k, v) in map {
out.insert(k.clone().into(), json_to_dynamic(v));
}
Dynamic::from_map(out)
}
}
}
fn build_engine(host: Arc<dyn RlmHostApi>, cell: SharedCellState, max_operations: u64) -> Engine {
let mut engine = Engine::new();
engine.set_max_operations(max_operations);
let progress_host = host.clone();
let progress_cell = cell.clone();
engine.on_progress(move |_ops| {
if progress_host.cancel_flag().is_cancelled() {
return Some(Dynamic::from(CANCELLED_TOKEN.to_string()));
}
if progress_cell
.lock()
.expect("cell state poisoned")
.fatal
.is_some()
{
return Some(Dynamic::from(DEADLINE_TOKEN.to_string()));
}
match progress_host.deadline() {
Some(deadline) if Instant::now() >= deadline => {
Some(Dynamic::from(DEADLINE_TOKEN.to_string()))
}
_ => None,
}
});
let print_cell = cell.clone();
engine.on_print(move |text| {
let mut state = print_cell.lock().expect("cell state poisoned");
state.stdout.push_str(text);
state.stdout.push('\n');
});
let debug_cell = cell.clone();
engine.on_debug(move |text, _source, _pos| {
let mut state = debug_cell.lock().expect("cell state poisoned");
state.stdout.push_str(text);
state.stdout.push('\n');
});
let llm_host = host.clone();
let llm_cell = cell.clone();
engine.register_fn(
"llm",
move |prompt: &str| -> std::result::Result<String, Box<EvalAltResult>> {
let value = dispatch(
&llm_host,
&llm_cell,
HostCall::Llm {
model: None,
prompt: prompt.to_string(),
system: None,
},
)?;
Ok(value.as_str().unwrap_or_default().to_string())
},
);
let llm_map_host = host.clone();
let llm_map_cell = cell.clone();
engine.register_fn(
"llm",
move |params: Map| -> std::result::Result<String, Box<EvalAltResult>> {
let get = |key: &str| params.get(key).and_then(|d| d.clone().into_string().ok());
let prompt = get("prompt").ok_or_else(|| {
Box::new(EvalAltResult::ErrorRuntime(
Dynamic::from("llm: missing `prompt`".to_string()),
Position::NONE,
))
})?;
let value = dispatch(
&llm_map_host,
&llm_map_cell,
HostCall::Llm {
model: get("model"),
prompt,
system: get("system"),
},
)?;
Ok(value.as_str().unwrap_or_default().to_string())
},
);
let tool_host = host.clone();
let tool_cell = cell.clone();
engine.register_fn(
"tool",
move |name: &str| -> std::result::Result<Dynamic, Box<EvalAltResult>> {
let value = dispatch(
&tool_host,
&tool_cell,
HostCall::Tool {
tool: name.to_string(),
arguments: Value::Null,
},
)?;
Ok(json_to_dynamic(&value))
},
);
let tool_args_host = host.clone();
let tool_args_cell = cell.clone();
engine.register_fn(
"tool",
move |name: &str, args: Map| -> std::result::Result<Dynamic, Box<EvalAltResult>> {
let arguments = dynamic_to_json(&Dynamic::from_map(args));
let value = dispatch(
&tool_args_host,
&tool_args_cell,
HostCall::Tool {
tool: name.to_string(),
arguments,
},
)?;
Ok(json_to_dynamic(&value))
},
);
let agent_host = host.clone();
let agent_cell = cell.clone();
engine.register_fn(
"agent",
move |name: &str, input: &str| -> std::result::Result<String, Box<EvalAltResult>> {
let value = dispatch(
&agent_host,
&agent_cell,
HostCall::Agent {
agent: name.to_string(),
input: input.to_string(),
data: None,
},
)?;
Ok(value.as_str().unwrap_or_default().to_string())
},
);
let answer_host = host.clone();
let answer_cell = cell.clone();
engine.register_fn(
"final_answer",
move |text: &str| -> std::result::Result<(), Box<EvalAltResult>> {
dispatch(
&answer_host,
&answer_cell,
HostCall::FinalAnswer {
answer: text.to_string(),
},
)?;
Ok(())
},
);
engine
}
#[async_trait]
impl RlmInterpreter for RhaiInterpreter {
fn language(&self) -> &str {
"rhai"
}
fn usage_guide(&self) -> String {
r#"Write Rhai. Variables persist across cells. Host functions:
- llm(prompt) -> string // ask the default sub-LLM
- llm(#{ model: "name", prompt: "...", system: "..." }) -> string
- tool("name", #{ arg: value, ... }) -> result // call a registered tool
- agent("name", "input") -> string // delegate to a sub-agent
- final_answer("...") // end the task with this answer
- print(x) // observe a value in the next turn
Errors raised by capabilities can be caught with try/catch. The last
expression of a cell is echoed back to you as its value.
Rhai syntax notes (Rhai is NOT JavaScript or Rust):
- There are NO tuples: return an array `[a, b]` or an object map `#{ k: v }`.
- Object maps are written `#{ name: value }` and indexed with `m.name` or `m["name"]`.
- Sub-arrays: `arr.extract(0..5)`; also `arr.len()`, `arr.push(x)`, `arr.filter(|x| ...)`,
`arr.map(|x| ...)`; loops: `for x in arr { ... }` and `for i in 0..n { ... }`.
- Strings concatenate with `+`; convert with `x.to_string()`; interpolate with `` `${x}` ``.
- `let` declares a mutable variable; statements end with `;`."#
.to_string()
}
async fn set_variable(&mut self, name: &str, value: Value) -> Result<()> {
self.scope
.set_value(name.to_string(), json_to_dynamic(&value));
Ok(())
}
async fn eval_cell(&mut self, code: &str, host: Arc<dyn RlmHostApi>) -> Result<CellEval> {
let cell: SharedCellState = Arc::new(Mutex::new(CellState::default()));
let engine = build_engine(host, cell.clone(), self.max_operations);
let mut scope = std::mem::take(&mut self.scope);
let code = code.to_string();
let (scope_back, eval) = tokio::task::spawn_blocking(move || {
let result = engine.eval_with_scope::<Dynamic>(&mut scope, &code);
(scope, result)
})
.await
.map_err(|err| TinyAgentsError::Model(format!("rlm rhai eval task failed: {err}")))?;
self.scope = scope_back;
let mut state = cell.lock().expect("cell state poisoned");
if let Some(fatal) = state.fatal.take() {
return Err(fatal);
}
let stdout = std::mem::take(&mut state.stdout);
drop(state);
match eval {
Ok(value) => {
let json = dynamic_to_json(&value);
Ok(CellEval {
stdout,
value: (!json.is_null()).then_some(json),
error: None,
})
}
Err(err) => {
let message = err.to_string();
if message.contains(DEADLINE_TOKEN) {
return Err(TinyAgentsError::Timeout(DEADLINE_TOKEN.to_string()));
}
if message.contains(CANCELLED_TOKEN) {
return Err(TinyAgentsError::Cancelled);
}
if matches!(*err, EvalAltResult::ErrorTooManyOperations(_)) {
return Err(TinyAgentsError::LimitExceeded(
"rlm cell exceeded the operation limit".to_string(),
));
}
Ok(CellEval {
stdout,
value: None,
error: Some(message),
})
}
}
}
async fn shutdown(&mut self) -> Result<()> {
Ok(())
}
}