use std::collections::BTreeMap;
use std::future::Future;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use rhai::{Array, Dynamic, Engine, EvalAltResult, Map, Position};
use serde_json::{Value, json};
use super::types::{GraphBlueprintHandle, LanguageCompiler, ReplCancelFlag, ReplPolicy};
use super::{
ReplCallKind, ReplCallRecord, dynamic_to_repl_value, json_to_repl_value, repl_value_to_dynamic,
};
use crate::error::TinyAgentsError;
use crate::harness::events::{AgentEvent, EventSink, ReplCallPhase};
use crate::harness::ids::{CallId, new_call_id};
use crate::harness::message::Message;
use crate::harness::model::ModelRequest;
use crate::harness::tool::ToolCall;
use crate::language::compiler::compile_with_provenance;
use crate::language::parser::parse_str;
use crate::language::resolver::Resolver;
use crate::language::types::Origin;
use crate::language::{Blueprint, blueprint_diff};
use crate::registry::CapabilityRegistry;
#[derive(Debug, Default, Clone, Copy)]
pub(super) struct CallCounters {
pub model: usize,
pub tool: usize,
pub graph: usize,
pub agent: usize,
pub graph_def: usize,
}
pub(super) struct HostContext<State: Send + Sync> {
pub registry: Arc<CapabilityRegistry<State>>,
pub state: Arc<State>,
pub policy: ReplPolicy,
pub language: Option<LanguageCompiler>,
pub session_label: String,
pub run_depth: usize,
pub events: EventSink,
pub cancel: ReplCancelFlag,
pub buffers: super::CellBuffers,
pub counters: Arc<Mutex<CallCounters>>,
pub drafts: Arc<Mutex<BTreeMap<String, GraphBlueprintHandle>>>,
}
const CANCEL_POLL_INTERVAL: Duration = Duration::from_millis(25);
enum BridgeStop {
Deadline,
Cancelled,
}
fn bridge_block_on_raw<F: Future>(
deadline: Option<Instant>,
cancel: &ReplCancelFlag,
future: F,
) -> std::result::Result<F::Output, TinyAgentsError> {
if cancel.is_cancelled() {
return Err(TinyAgentsError::Cancelled);
}
if let Some(deadline) = deadline
&& Instant::now() >= deadline
{
return Err(TinyAgentsError::Timeout(format!(
"{DEADLINE_EXCEEDED_TOKEN} before a host capability call could start"
)));
}
let (tx, rx) = futures::channel::oneshot::channel::<BridgeStop>();
let watcher_cancel = cancel.clone();
std::thread::spawn(move || {
loop {
if tx.is_canceled() {
return;
}
if watcher_cancel.is_cancelled() {
let _ = tx.send(BridgeStop::Cancelled);
return;
}
match deadline {
Some(deadline) => {
let now = Instant::now();
if now >= deadline {
let _ = tx.send(BridgeStop::Deadline);
return;
}
std::thread::sleep((deadline - now).min(CANCEL_POLL_INTERVAL));
}
None => std::thread::sleep(CANCEL_POLL_INTERVAL),
}
}
});
match futures::executor::block_on(futures::future::select(Box::pin(future), rx)) {
futures::future::Either::Left((output, _watcher)) => Ok(output),
futures::future::Either::Right((stop, _fut)) => match stop {
Ok(BridgeStop::Cancelled) => Err(TinyAgentsError::Cancelled),
Ok(BridgeStop::Deadline) => Err(TinyAgentsError::Timeout(format!(
"{DEADLINE_EXCEEDED_TOKEN} during a host capability call"
))),
Err(_canceled) => {
if cancel.is_cancelled() {
Err(TinyAgentsError::Cancelled)
} else {
Err(TinyAgentsError::Timeout(format!(
"{DEADLINE_EXCEEDED_TOKEN} during a host capability call"
)))
}
}
},
}
}
fn bridge_block_on<T, F>(
deadline: Option<Instant>,
cancel: &ReplCancelFlag,
future: F,
) -> std::result::Result<T, TinyAgentsError>
where
F: Future<Output = std::result::Result<T, TinyAgentsError>>,
{
bridge_block_on_raw(deadline, cancel, future)?
}
type ModelBatchItem = (String, String, Option<String>, bool, Duration);
type AgentBatchItem = (String, String, Duration);
fn raise<State: Send + Sync>(ctx: &HostContext<State>, err: TinyAgentsError) -> Box<EvalAltResult> {
let message = err.to_string();
ctx.buffers.set_host_error(err);
Box::new(EvalAltResult::ErrorRuntime(
Dynamic::from(message),
Position::NONE,
))
}
fn invalid<State: Send + Sync>(
ctx: &HostContext<State>,
message: impl Into<String>,
) -> Box<EvalAltResult> {
raise(ctx, TinyAgentsError::Validation(message.into()))
}
fn record<State: Send + Sync>(
ctx: &HostContext<State>,
call_id: CallId,
kind: ReplCallKind,
name: &str,
detail: Value,
elapsed: Duration,
) {
let record = ReplCallRecord {
call_id,
kind,
name: name.to_string(),
detail,
elapsed,
};
emit_repl_call(ctx, &record, ReplCallPhase::Completed);
ctx.buffers.push_call(record);
}
fn emit_repl_call<State: Send + Sync>(
ctx: &HostContext<State>,
record: &ReplCallRecord,
phase: ReplCallPhase,
) {
ctx.events.emit(AgentEvent::ReplCall {
session_id: ctx.session_label.clone(),
record: record.clone(),
phase,
});
}
fn emit_call_started<State: Send + Sync>(
ctx: &HostContext<State>,
call_id: &CallId,
kind: ReplCallKind,
name: &str,
) {
let record = ReplCallRecord {
call_id: call_id.clone(),
kind,
name: name.to_string(),
detail: Value::Null,
elapsed: Duration::default(),
};
emit_repl_call(ctx, &record, ReplCallPhase::Started);
}
fn map_str(map: &Map, key: &str) -> Option<String> {
map.get(key).and_then(|d| d.clone().into_string().ok())
}
fn map_bool(map: &Map, key: &str) -> Option<bool> {
map.get(key).and_then(|d| d.as_bool().ok())
}
fn map_json(map: &Map, key: &str) -> Option<Value> {
map.get(key)
.map(|d| dynamic_to_repl_value(d).to_json())
.filter(|v| !v.is_null())
}
fn bump_model<State: Send + Sync>(ctx: &HostContext<State>) -> Result<(), Box<EvalAltResult>> {
let mut counters = ctx.counters.lock().expect("counters poisoned");
if counters.model >= ctx.policy.max_model_calls {
return Err(raise(
ctx,
TinyAgentsError::LimitExceeded(format!(
"model call limit ({}) exceeded",
ctx.policy.max_model_calls
)),
));
}
counters.model += 1;
Ok(())
}
fn bump_tool<State: Send + Sync>(ctx: &HostContext<State>) -> Result<(), Box<EvalAltResult>> {
let mut counters = ctx.counters.lock().expect("counters poisoned");
if counters.tool >= ctx.policy.max_tool_calls {
return Err(raise(
ctx,
TinyAgentsError::LimitExceeded(format!(
"tool call limit ({}) exceeded",
ctx.policy.max_tool_calls
)),
));
}
counters.tool += 1;
Ok(())
}
fn bump_graph<State: Send + Sync>(ctx: &HostContext<State>) -> Result<(), Box<EvalAltResult>> {
let mut counters = ctx.counters.lock().expect("counters poisoned");
if counters.graph >= ctx.policy.max_graph_calls {
return Err(raise(
ctx,
TinyAgentsError::LimitExceeded(format!(
"graph call limit ({}) exceeded",
ctx.policy.max_graph_calls
)),
));
}
counters.graph += 1;
Ok(())
}
fn bump_agent<State: Send + Sync>(ctx: &HostContext<State>) -> Result<(), Box<EvalAltResult>> {
let mut counters = ctx.counters.lock().expect("counters poisoned");
if counters.agent >= ctx.policy.max_agent_calls {
return Err(raise(
ctx,
TinyAgentsError::LimitExceeded(format!(
"agent call limit ({}) exceeded",
ctx.policy.max_agent_calls
)),
));
}
counters.agent += 1;
Ok(())
}
fn check_depth<State: Send + Sync>(ctx: &HostContext<State>) -> Result<(), Box<EvalAltResult>> {
crate::harness::context::RunConfig::checked_child_depth(ctx.run_depth, ctx.policy.max_depth)
.map(|_| ())
.map_err(|err| raise(ctx, err))
}
fn build_model_request(model: &str, params: &Map) -> ModelRequest {
let mut messages = Vec::new();
if let Some(system) = map_str(params, "system") {
messages.push(Message::system(system));
}
if let Some(prompt) = map_str(params, "prompt") {
messages.push(Message::user(prompt));
}
ModelRequest {
messages,
model: Some(model.to_string()),
..Default::default()
}
}
fn model_value(text: String, finish_reason: Option<String>, structured: bool) -> Dynamic {
if structured {
let mut map = Map::new();
map.insert("content".into(), Dynamic::from(text));
if let Some(reason) = finish_reason {
map.insert("finish_reason".into(), Dynamic::from(reason));
}
Dynamic::from_map(map)
} else {
Dynamic::from(text)
}
}
mod authoring;
mod batched;
mod capabilities;
use authoring::*;
use batched::*;
use capabilities::*;
pub(super) const DEADLINE_EXCEEDED_TOKEN: &str = "ragsh cell exceeded its wall-clock timeout";
pub(super) const CANCELLED_TOKEN: &str = "ragsh cell cancelled by host";
pub(super) fn build_engine<State: Send + Sync + 'static>(ctx: Arc<HostContext<State>>) -> Engine {
let mut engine = Engine::new();
engine.set_max_operations(ctx.policy.max_operations);
let deadline_ctx = ctx.clone();
engine.on_progress(move |_ops| {
if deadline_ctx.cancel.is_cancelled() {
return Some(Dynamic::from(CANCELLED_TOKEN.to_string()));
}
if deadline_ctx.buffers.host_error_pending() {
return Some(Dynamic::from(DEADLINE_EXCEEDED_TOKEN.to_string()));
}
match deadline_ctx.buffers.deadline() {
Some(deadline) if Instant::now() >= deadline => {
Some(Dynamic::from(DEADLINE_EXCEEDED_TOKEN.to_string()))
}
_ => None,
}
});
let stdout_ctx = ctx.clone();
engine.on_print(move |text| stdout_ctx.buffers.push_stdout_line(text));
let debug_ctx = ctx.clone();
engine.on_debug(move |text, _source, _pos| debug_ctx.buffers.push_stdout_line(text));
let emit_ctx = ctx.clone();
engine.register_fn("emit", move |name: &str| {
record(
&emit_ctx,
new_call_id(),
ReplCallKind::Emit,
name,
Value::Null,
Duration::default(),
);
});
let emit_payload_ctx = ctx.clone();
engine.register_fn("emit", move |name: &str, data: Map| {
let detail = dynamic_to_repl_value(&Dynamic::from_map(data)).to_json();
record(
&emit_payload_ctx,
new_call_id(),
ReplCallKind::Emit,
name,
detail,
Duration::default(),
);
});
let answer_ctx = ctx.clone();
engine.register_fn("answer", move |content: &str| {
answer_ctx.buffers.set_answer(content.to_string());
});
let show_ctx = ctx.clone();
engine.register_fn("show_vars", move || {
show_ctx.buffers.push_stdout_line("# vars");
for (name, value) in show_ctx.buffers.vars_snapshot() {
show_ctx
.buffers
.push_stdout_line(&format!("{name} = {value}"));
}
});
let model_ctx = ctx.clone();
engine.register_fn("model_query", move |params: Map| {
model_query_impl(&model_ctx, ¶ms)
});
let model_batch_ctx = ctx.clone();
engine.register_fn("model_query_batched", move |items: Array| {
model_query_batched_impl(&model_batch_ctx, &items)
});
let tool_ctx = ctx.clone();
engine.register_fn("tool_call", move |params: Map| {
tool_call_impl(&tool_ctx, ¶ms)
});
let tool_batch_ctx = ctx.clone();
engine.register_fn("tool_call_batched", move |items: Array| {
tool_call_batched_impl(&tool_batch_ctx, &items)
});
let agent_ctx = ctx.clone();
engine.register_fn("agent_query", move |params: Map| {
agent_query_impl(&agent_ctx, ¶ms)
});
let agent_batch_ctx = ctx.clone();
engine.register_fn("agent_query_batched", move |items: Array| {
agent_query_batched_impl(&agent_batch_ctx, &items)
});
let graph_ctx = ctx.clone();
engine.register_fn("graph_run", move |params: Map| {
graph_run_impl(&graph_ctx, ¶ms)
});
let graph_batch_ctx = ctx.clone();
engine.register_fn("graph_run_batched", move |items: Array| {
graph_run_batched_impl(&graph_batch_ctx, &items)
});
let define_ctx = ctx.clone();
engine.register_fn("graph_define", move |params: Map| {
graph_define_impl(&define_ctx, ¶ms)
});
let validate_ctx = ctx.clone();
engine.register_fn("graph_validate", move |descriptor: Map| {
graph_validate_impl(&validate_ctx, &descriptor)
});
let compile_ctx = ctx.clone();
engine.register_fn("graph_compile", move |descriptor: Map| {
graph_compile_impl(&compile_ctx, &descriptor)
});
let diff_name_ctx = ctx.clone();
engine.register_fn(
"graph_diff",
move |name: &str, draft: Map| -> Result<Dynamic, Box<EvalAltResult>> {
let old = diff_name_ctx
.registry
.graph_blueprint(name)
.ok_or_else(|| {
invalid(
&diff_name_ctx,
format!("graph_diff: graph `{name}` is not registered"),
)
})?
.clone();
let new = lookup_draft(&diff_name_ctx, &draft, "graph_diff")?;
graph_diff_handles(&diff_name_ctx, &old, &new.blueprint)
},
);
let diff_draft_ctx = ctx.clone();
engine.register_fn(
"graph_diff",
move |old: Map, new: Map| -> Result<Dynamic, Box<EvalAltResult>> {
let old = lookup_draft(&diff_draft_ctx, &old, "graph_diff")?;
let new = lookup_draft(&diff_draft_ctx, &new, "graph_diff")?;
graph_diff_handles(&diff_draft_ctx, &old.blueprint, &new.blueprint)
},
);
let register_ctx = ctx.clone();
engine.register_fn("graph_register", move |params: Map| {
graph_register_impl(®ister_ctx, ¶ms)
});
engine
}
#[cfg(test)]
mod bridge_deadline_test {
use super::*;
#[test]
fn no_deadline_awaits_to_completion() {
let out = bridge_block_on::<u32, _>(None, &ReplCancelFlag::new(), async { Ok(7) })
.expect("no deadline");
assert_eq!(out, 7);
}
#[test]
fn future_finishing_before_the_deadline_succeeds() {
let deadline = Instant::now() + Duration::from_secs(5);
let out =
bridge_block_on::<u32, _>(Some(deadline), &ReplCancelFlag::new(), async { Ok(9) })
.expect("within deadline");
assert_eq!(out, 9);
}
#[test]
fn deadline_already_elapsed_fails_closed_without_starting_the_call() {
let deadline = Instant::now() - Duration::from_millis(1);
let err =
bridge_block_on::<u32, _>(Some(deadline), &ReplCancelFlag::new(), async { Ok(1) })
.expect_err("deadline already passed");
assert!(matches!(err, TinyAgentsError::Timeout(_)), "got {err:?}");
}
#[test]
fn a_hanging_call_is_cut_off_at_the_deadline_instead_of_blocking_forever() {
let start = Instant::now();
let deadline = start + Duration::from_millis(30);
let err = bridge_block_on::<u32, _>(
Some(deadline),
&ReplCancelFlag::new(),
futures::future::pending::<std::result::Result<u32, TinyAgentsError>>(),
)
.expect_err("hanging call must be cut off at the deadline");
assert!(matches!(err, TinyAgentsError::Timeout(_)), "got {err:?}");
assert!(
start.elapsed() < Duration::from_secs(5),
"took {:?}, should return promptly at the 30ms deadline",
start.elapsed()
);
}
#[test]
fn cancel_already_set_fails_closed_without_starting_the_call() {
let cancel = ReplCancelFlag::new();
cancel.cancel();
let err = bridge_block_on::<u32, _>(
None,
&cancel,
futures::future::pending::<std::result::Result<u32, TinyAgentsError>>(),
)
.expect_err("pre-cancelled call must not start");
assert!(matches!(err, TinyAgentsError::Cancelled), "got {err:?}");
}
#[test]
fn a_hanging_call_is_cut_off_promptly_when_the_cancel_flag_trips() {
let start = Instant::now();
let cancel = ReplCancelFlag::new();
let trigger = cancel.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(40));
trigger.cancel();
});
let err = bridge_block_on::<u32, _>(
None,
&cancel,
futures::future::pending::<std::result::Result<u32, TinyAgentsError>>(),
)
.expect_err("hanging call must be cut off on cancel");
assert!(matches!(err, TinyAgentsError::Cancelled), "got {err:?}");
assert!(
start.elapsed() < Duration::from_secs(5),
"took {:?}, should return promptly after the ~40ms cancel",
start.elapsed()
);
}
}