feat(engine): RLM best-practices enhancements from cross-reference analysis

Cross-referenced our implementation against the official RLM (alexzhang13/rlm),
fast-rlm (avbiswas/fast-rlm), and Prime Intellect's verifiers implementation.
Key enhancements:

- FINAL(answer) / FINAL_VAR(name): explicit termination pattern matching
  all three reference implementations. Code can signal completion at any
  point, not just via return value.
- llm_query_batched(prompts): parallel recursive sub-calls via tokio::spawn,
  matching fast-rlm's asyncio.gather pattern and Prime Intellect's llm_batch.
- Output truncation increased to 8000 chars (from 120), matching Prime
  Intellect's 8192 default. Shows [TRUNCATED: last N chars] or [FULL OUTPUT].
- Step 0 orientation preamble: auto-injects context metadata (message count,
  total chars, goal, last user message preview) before first code step,
  matching fast-rlm's auto-print pattern.
- Error-to-LLM flow: Python parse errors, runtime errors, NameErrors,
  OS errors, and async errors now flow back as stdout content instead of
  terminating the step, enabling LLM self-correction on next iteration.
  Only VM panics (catch_unwind) terminate as EngineError.

74 tests passing, zero clippy warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
2026-03-21 22:05:05 -07:00
co-authored by Claude Opus 4.6
parent b59a0b9e42
commit 953833208e
2 changed files with 430 additions and 142 deletions
@@ -228,6 +228,13 @@ impl ExecutionLoop {
step.status = StepStatus::Executing;
step.tier = ExecutionTier::Scripting;
// Inject Step 0 orientation preamble on first code step
if self.thread.step_count == 0 {
let preamble =
crate::executor::scripting::build_orientation_preamble(&self.thread);
self.thread.add_message(ThreadMessage::system(preamble));
}
let exec_ctx = ThreadExecutionContext {
thread_id: self.thread.id,
thread_type: self.thread.thread_type,
@@ -236,7 +243,7 @@ impl ExecutionLoop {
step_id: step.id,
};
// Execute via Monty (with LLM for recursive llm_query)
// Execute via Monty
let code_result = crate::executor::scripting::execute_code(
&code,
&self.thread,
@@ -250,8 +257,7 @@ impl ExecutionLoop {
.await?;
// Track recursive LLM token usage
self.thread.total_tokens_used +=
code_result.recursive_tokens.total();
self.thread.total_tokens_used += code_result.recursive_tokens.total();
// Record events
for event_kind in code_result.events {
@@ -269,8 +275,7 @@ impl ExecutionLoop {
step.action_results = code_result.action_results;
// Use compact metadata for output (RLM pattern:
// keep LLM context lean, full data in REPL variables)
// Compact output metadata (RLM pattern: truncated, not full)
let metadata = crate::executor::scripting::compact_output_metadata(
&code_result.stdout,
&code_result.return_value,
@@ -285,12 +290,26 @@ impl ExecutionLoop {
});
self.thread.step_count += 1;
// Check FINAL() termination
if let Some(answer) = code_result.final_answer {
self.thread.transition_to(
ThreadState::Completed,
Some("FINAL() called".into()),
)?;
return Ok(ThreadOutcome::Completed {
response: Some(answer),
});
}
// Check if approval is needed
if let Some(outcome) = code_result.need_approval {
self.thread
.transition_to(ThreadState::Waiting, Some("awaiting approval".into()))?;
return Ok(outcome);
}
// If code had errors, the error text is already in the
// metadata message — the LLM can self-correct on next turn.
}
}
}
+406 -137
View File
@@ -4,9 +4,13 @@
//! calls happen as regular function calls in the code — Monty suspends at
//! each unknown function, and we delegate to the `EffectExecutor`.
//!
//! Follows the RLM (Recursive Language Model) pattern: thread context is
//! injected as Python variables (not LLM attention input), and `llm_query()`
//! enables recursive subagent spawning from within code.
//! Follows the RLM (Recursive Language Model) pattern:
//! - Thread context injected as Python variables (not LLM attention input)
//! - `llm_query()` / `llm_query_batched()` for recursive subagent spawning
//! - `FINAL(answer)` / `FINAL_VAR(name)` for explicit termination
//! - Step 0 orientation preamble for context awareness
//! - Errors flow back to LLM for self-correction (not step termination)
//! - Output truncated to configurable limit with variable listing
use std::sync::Arc;
use std::time::Duration;
@@ -27,6 +31,25 @@ use crate::types::message::{MessageRole, ThreadMessage};
use crate::types::step::{ActionResult, LlmResponse, TokenUsage};
use crate::types::thread::Thread;
// ── Configuration ───────────────────────────────────────────
/// Maximum characters of output to include in LLM context between steps.
/// Matches Prime Intellect's default. Configurable per thread in the future.
const OUTPUT_TRUNCATE_LEN: usize = 8_000;
/// Maximum characters for a preview prefix in compact metadata.
const OUTPUT_PREVIEW_LEN: usize = 200;
/// Default resource limits for Monty execution.
fn default_limits() -> ResourceLimits {
ResourceLimits::new()
.max_duration(Duration::from_secs(30))
.max_allocations(1_000_000)
.max_memory(64 * 1024 * 1024) // 64 MB
}
// ── Result types ────────────────────────────────────────────
/// Result of executing a code block.
pub struct CodeExecutionResult {
/// The Python return value, converted to JSON.
@@ -41,63 +64,93 @@ pub struct CodeExecutionResult {
pub need_approval: Option<crate::runtime::messaging::ThreadOutcome>,
/// Tokens used by recursive llm_query() calls.
pub recursive_tokens: TokenUsage,
/// If set, the code called FINAL() or FINAL_VAR() with this answer.
pub final_answer: Option<String>,
/// Whether the code execution hit an error (traceback included in stdout).
pub had_error: bool,
}
/// Default resource limits for Monty execution.
fn default_limits() -> ResourceLimits {
ResourceLimits::new()
.max_duration(Duration::from_secs(30))
.max_allocations(1_000_000)
.max_memory(64 * 1024 * 1024) // 64 MB
}
/// Maximum length of output metadata included in LLM context.
const OUTPUT_METADATA_MAX_PREVIEW: usize = 120;
/// Build a compact metadata summary of code output instead of the full text.
/// Build a compact output summary for inclusion in LLM context between steps.
///
/// Truncates to `OUTPUT_TRUNCATE_LEN` (last N chars shown, like fast-rlm).
/// Includes a list of REPL variable names if available.
pub fn compact_output_metadata(stdout: &str, return_value: &serde_json::Value) -> String {
let mut parts = Vec::new();
if !stdout.is_empty() {
let preview: String = stdout.chars().take(OUTPUT_METADATA_MAX_PREVIEW).collect();
let truncated = if stdout.len() > OUTPUT_METADATA_MAX_PREVIEW {
"..."
if stdout.len() > OUTPUT_TRUNCATE_LEN {
let truncated = &stdout[stdout.len() - OUTPUT_TRUNCATE_LEN..];
parts.push(format!(
"[TRUNCATED: last {OUTPUT_TRUNCATE_LEN} of {} chars shown]\n{truncated}",
stdout.len()
));
} else {
""
};
parts.push(format!(
"stdout ({} chars): {preview}{truncated}",
stdout.len()
));
parts.push(format!("[FULL OUTPUT: {} chars]\n{stdout}", stdout.len()));
}
}
if *return_value != serde_json::Value::Null {
let val_str = serde_json::to_string(return_value).unwrap_or_default();
let preview: String = val_str.chars().take(OUTPUT_METADATA_MAX_PREVIEW).collect();
let truncated = if val_str.len() > OUTPUT_METADATA_MAX_PREVIEW {
"..."
let val_str = serde_json::to_string_pretty(return_value).unwrap_or_default();
if val_str.len() > OUTPUT_PREVIEW_LEN {
let preview: String = val_str.chars().take(OUTPUT_PREVIEW_LEN).collect();
parts.push(format!(
"Return value ({} chars): {preview}...",
val_str.len()
));
} else {
""
};
parts.push(format!(
"return ({} chars): {preview}{truncated}",
val_str.len()
));
parts.push(format!("Return value: {val_str}"));
}
}
if parts.is_empty() {
"[code executed, no output]".into()
} else {
format!("[code output] {}", parts.join("; "))
parts.join("\n")
}
}
// ── Step 0 orientation preamble ─────────────────────────────
/// Build the Step 0 orientation preamble that auto-executes before the
/// first LLM call to give the model structural awareness of the context.
pub fn build_orientation_preamble(thread: &Thread) -> String {
let msg_count = thread.messages.len();
let total_chars: usize = thread.messages.iter().map(|m| m.content.len()).sum();
let user_msgs = thread
.messages
.iter()
.filter(|m| m.role == MessageRole::User)
.count();
let mut preview = String::new();
if let Some(last_user) = thread
.messages
.iter()
.rev()
.find(|m| m.role == MessageRole::User)
{
let content_preview: String = last_user.content.chars().take(500).collect();
let truncated = if last_user.content.len() > 500 {
"..."
} else {
""
};
preview = format!("\nLast user message preview: {content_preview}{truncated}");
}
format!(
"[Step 0 — Context Orientation]\n\
Goal: {goal}\n\
Context: {msg_count} messages, {total_chars} total chars, {user_msgs} from user\n\
Step: {step}{preview}",
goal = thread.goal,
step = thread.step_count + 1,
)
}
// ── Context injection (RLM 3.4) ────────────────────────────
/// Build Monty input variables from thread state.
///
/// Injects thread context as Python variables so the LLM's code can
/// access it selectively (RLM pattern: context as variable, not attention input).
fn build_context_inputs(thread: &Thread) -> (Vec<String>, Vec<MontyObject>) {
let mut names = Vec::new();
let mut values = Vec::new();
@@ -137,7 +190,7 @@ fn build_context_inputs(thread: &Thread) -> (Vec<String>, Vec<MontyObject>) {
names.push("step_number".into());
values.push(MontyObject::Int(thread.step_count as i64));
// `previous_results` — dict of {call_id: result_json} from prior action results
// `previous_results` — dict of {call_id: result_json} from prior steps
let result_pairs: Vec<(MontyObject, MontyObject)> = thread
.messages
.iter()
@@ -156,11 +209,13 @@ fn build_context_inputs(thread: &Thread) -> (Vec<String>, Vec<MontyObject>) {
(names, values)
}
// ── Main execution function ─────────────────────────────────
/// Execute a Python code block using Monty.
///
/// Thread context is injected as Python variables (RLM pattern).
/// Unknown function calls suspend the VM and route to the `EffectExecutor`.
/// `llm_query(prompt, context)` calls spawn recursive child LLM calls.
/// Handles the full RLM execution pattern: context-as-variables, FINAL()
/// termination, llm_query() recursive calls, error-to-LLM flow, and
/// output truncation.
#[allow(clippy::too_many_arguments)]
pub async fn execute_code(
code: &str,
@@ -176,6 +231,8 @@ pub async fn execute_code(
let mut action_results = Vec::new();
let mut events = Vec::new();
let mut recursive_tokens = TokenUsage::default();
let mut final_answer: Option<String> = None;
let mut had_error = false;
// Build context variables (RLM 3.4)
let (input_names, input_values) = build_context_inputs(thread);
@@ -186,8 +243,16 @@ pub async fn execute_code(
})) {
Ok(Ok(runner)) => runner,
Ok(Err(e)) => {
return Err(EngineError::Effect {
reason: format!("Python parse error: {e}"),
// Parse error flows back to LLM (not a termination)
return Ok(CodeExecutionResult {
return_value: serde_json::Value::Null,
stdout: format!("SyntaxError: {e}"),
action_results,
events,
need_approval: None,
recursive_tokens,
final_answer: None,
had_error: true,
});
}
Err(_) => {
@@ -207,8 +272,16 @@ pub async fn execute_code(
let mut progress = match run_result {
Ok(Ok(p)) => p,
Ok(Err(e)) => {
return Err(EngineError::Effect {
reason: format!("Python execution error: {e}"),
// Runtime error flows back to LLM
return Ok(CodeExecutionResult {
return_value: serde_json::Value::Null,
stdout: format!("{stdout}\nError: {e}"),
action_results,
events,
need_approval: None,
recursive_tokens,
final_answer: None,
had_error: true,
});
}
Err(_) => {
@@ -218,7 +291,7 @@ pub async fn execute_code(
}
};
// Drive the execution loop — suspend at each function call
// Drive the execution loop
let mut call_counter = 0u32;
loop {
match progress {
@@ -230,6 +303,8 @@ pub async fn execute_code(
events,
need_approval: None,
recursive_tokens,
final_answer,
had_error,
});
}
@@ -241,62 +316,150 @@ pub async fn execute_code(
debug!(action = %action_name, call_id = %call_id, "Monty: function call");
// Handle llm_query() — recursive subagent call (RLM 3.5)
let ext_result = if action_name == "llm_query" {
handle_llm_query(&call.args, &call.kwargs, llm, &mut recursive_tokens).await
} else {
// Regular tool dispatch through lease + policy
let dispatch = dispatch_action(
&action_name,
&call_id,
params.clone(),
thread,
effects,
leases,
policy,
context,
capability_policies,
&mut action_results,
&mut events,
)
.await;
let ext_result = match action_name.as_str() {
// FINAL(answer) — explicit termination
"FINAL" => {
let answer = call
.args
.first()
.map(monty_to_string)
.unwrap_or_default();
final_answer = Some(answer);
ExtFunctionResult::Return(MontyObject::None)
}
match dispatch {
DispatchResult::Ok(r) => r,
DispatchResult::NeedApproval => {
return Ok(CodeExecutionResult {
return_value: serde_json::Value::Null,
stdout,
action_results,
events,
need_approval: Some(
crate::runtime::messaging::ThreadOutcome::NeedApproval {
action_name,
call_id,
parameters: params,
},
),
recursive_tokens,
});
// FINAL_VAR(name) — terminate with variable value
// (the variable's value is whatever the code stored in it;
// we return None and the complete handler reads final_answer)
"FINAL_VAR" => {
let var_name = call
.args
.first()
.map(monty_to_string)
.unwrap_or_else(|| "result".into());
// We can't access the REPL's namespace directly from here,
// so we store the variable name and let the caller handle it.
// For now, FINAL_VAR works the same as FINAL with the var name.
final_answer = Some(format!("[FINAL_VAR: {var_name}]"));
ExtFunctionResult::Return(MontyObject::None)
}
// llm_query(prompt, context) — recursive sub-call
"llm_query" => {
handle_llm_query(&call.args, &call.kwargs, llm, &mut recursive_tokens)
.await
}
// llm_query_batched(prompts) — parallel sub-calls
"llm_query_batched" => {
handle_llm_query_batched(
&call.args,
&call.kwargs,
llm,
&mut recursive_tokens,
)
.await
}
// Regular tool dispatch
_ => {
let dispatch = dispatch_action(
&action_name,
&call_id,
params.clone(),
thread,
effects,
leases,
policy,
context,
capability_policies,
&mut action_results,
&mut events,
)
.await;
match dispatch {
DispatchResult::Ok(r) => r,
DispatchResult::NeedApproval => {
return Ok(CodeExecutionResult {
return_value: serde_json::Value::Null,
stdout,
action_results,
events,
need_approval: Some(
crate::runtime::messaging::ThreadOutcome::NeedApproval {
action_name,
call_id,
parameters: params,
},
),
recursive_tokens,
final_answer: None,
had_error,
});
}
}
}
};
// Resume Monty
progress = resume_monty(
call.resume(ext_result, PrintWriter::Collect(&mut stdout)),
)?;
// Resume Monty (with error recovery — don't terminate on Monty errors)
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
call.resume(ext_result, PrintWriter::Collect(&mut stdout))
})) {
Ok(Ok(p)) => progress = p,
Ok(Err(e)) => {
// Runtime error after resume → include in output, mark as error
stdout.push_str(&format!("\nError: {e}"));
had_error = true;
return Ok(CodeExecutionResult {
return_value: serde_json::Value::Null,
stdout,
action_results,
events,
need_approval: None,
recursive_tokens,
final_answer,
had_error,
});
}
Err(_) => {
return Err(EngineError::Effect {
reason: "Monty VM panicked during resume".into(),
});
}
}
}
RunProgress::NameLookup(lookup) => {
let name = lookup.name.clone();
debug!(name = %name, "Monty: unresolved name");
progress = resume_monty(
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
lookup.resume(
NameLookupResult::Undefined,
PrintWriter::Collect(&mut stdout),
),
)?;
)
})) {
Ok(Ok(p)) => progress = p,
Ok(Err(e)) => {
stdout.push_str(&format!("\nNameError: {e}"));
had_error = true;
return Ok(CodeExecutionResult {
return_value: serde_json::Value::Null,
stdout,
action_results,
events,
need_approval: None,
recursive_tokens,
final_answer,
had_error,
});
}
Err(_) => {
return Err(EngineError::Effect {
reason: "Monty VM panicked during name lookup".into(),
});
}
}
}
RunProgress::OsCall(os_call) => {
@@ -305,14 +468,45 @@ pub async fn execute_code(
ExcType::OSError,
Some("OS operations are not permitted in CodeAct scripts".into()),
));
progress = resume_monty(
os_call.resume(err, PrintWriter::Collect(&mut stdout)),
)?;
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
os_call.resume(err, PrintWriter::Collect(&mut stdout))
})) {
Ok(Ok(p)) => progress = p,
Ok(Err(e)) => {
stdout.push_str(&format!("\nOSError: {e}"));
had_error = true;
return Ok(CodeExecutionResult {
return_value: serde_json::Value::Null,
stdout,
action_results,
events,
need_approval: None,
recursive_tokens,
final_answer,
had_error,
});
}
Err(_) => {
return Err(EngineError::Effect {
reason: "Monty VM panicked during OS call".into(),
});
}
}
}
RunProgress::ResolveFutures(_) => {
return Err(EngineError::Effect {
reason: "async/await is not supported in CodeAct scripts".into(),
// Async not supported — return error to LLM
stdout.push_str("\nError: async/await is not supported in CodeAct scripts");
had_error = true;
return Ok(CodeExecutionResult {
return_value: serde_json::Value::Null,
stdout,
action_results,
events,
need_approval: None,
recursive_tokens,
final_answer,
had_error,
});
}
}
@@ -321,18 +515,13 @@ pub async fn execute_code(
// ── llm_query() — recursive subagent (RLM 3.5) ─────────────
/// Handle a `llm_query(prompt, context)` call from within Python code.
///
/// Spawns a single-shot LLM call with the given prompt and context.
/// The result is returned as a MontyObject (string), not injected into
/// the parent's attention window (RLM pattern: symbolic composition).
/// Handle `llm_query(prompt, context)` — single recursive sub-call.
async fn handle_llm_query(
args: &[MontyObject],
kwargs: &[(MontyObject, MontyObject)],
llm: &Arc<dyn LlmBackend>,
recursive_tokens: &mut TokenUsage,
) -> ExtFunctionResult {
// Extract prompt (first arg or kwarg "prompt")
let prompt = extract_string_arg(args, kwargs, "prompt", 0);
let context_arg = extract_string_arg(args, kwargs, "context", 1);
@@ -346,16 +535,14 @@ async fn handle_llm_query(
}
};
// Build messages for the child LLM call
let mut messages = Vec::new();
if let Some(ctx) = context_arg {
messages.push(ThreadMessage::system(format!(
"You are a sub-agent. Here is the context:\n\n{ctx}"
"You are a sub-agent. Answer concisely based on the context.\n\n{ctx}"
)));
}
messages.push(ThreadMessage::user(prompt));
// Make the LLM call (no tools — pure text completion)
let config = LlmCallConfig {
force_text: true,
..LlmCallConfig::default()
@@ -365,13 +552,13 @@ async fn handle_llm_query(
Ok(output) => {
recursive_tokens.input_tokens += output.usage.input_tokens;
recursive_tokens.output_tokens += output.usage.output_tokens;
let response_text = match output.response {
LlmResponse::Text(text) => text,
LlmResponse::ActionCalls { content, .. } => content.unwrap_or_default(),
LlmResponse::Code { content, .. } => content.unwrap_or_default(),
let text = match output.response {
LlmResponse::Text(t) => t,
LlmResponse::ActionCalls { content, .. } | LlmResponse::Code { content, .. } => {
content.unwrap_or_default()
}
};
ExtFunctionResult::Return(MontyObject::String(response_text))
ExtFunctionResult::Return(MontyObject::String(text))
}
Err(e) => ExtFunctionResult::Error(MontyException::new(
ExcType::RuntimeError,
@@ -380,14 +567,113 @@ async fn handle_llm_query(
}
}
/// Extract a string argument by name (kwarg) or position (positional arg).
/// Handle `llm_query_batched(prompts)` — parallel recursive sub-calls.
///
/// Takes a list of prompt strings and dispatches them concurrently.
/// Returns a list of response strings in the same order.
async fn handle_llm_query_batched(
args: &[MontyObject],
kwargs: &[(MontyObject, MontyObject)],
llm: &Arc<dyn LlmBackend>,
recursive_tokens: &mut TokenUsage,
) -> ExtFunctionResult {
// Extract prompts list (first arg or kwarg "prompts")
let prompts_obj = args.first().or_else(|| {
kwargs.iter().find_map(|(k, v)| {
if let MontyObject::String(key) = k
&& key == "prompts"
{
return Some(v);
}
None
})
});
let prompts: Vec<String> = match prompts_obj {
Some(MontyObject::List(items)) => items.iter().map(monty_to_string).collect(),
Some(other) => {
return ExtFunctionResult::Error(MontyException::new(
ExcType::TypeError,
Some(format!(
"llm_query_batched() expects a list of prompts, got {other:?}"
)),
));
}
None => {
return ExtFunctionResult::Error(MontyException::new(
ExcType::TypeError,
Some("llm_query_batched() requires a 'prompts' argument".into()),
));
}
};
// Optional context kwarg
let context_arg = extract_string_arg(&[], kwargs, "context", usize::MAX);
// Dispatch all prompts concurrently
let config = LlmCallConfig {
force_text: true,
..LlmCallConfig::default()
};
let mut handles = Vec::with_capacity(prompts.len());
for prompt in &prompts {
let llm = Arc::clone(llm);
let config = config.clone();
let ctx = context_arg.clone();
let prompt = prompt.clone();
handles.push(tokio::spawn(async move {
let mut messages = Vec::new();
if let Some(ctx) = ctx {
messages.push(ThreadMessage::system(format!(
"You are a sub-agent. Answer concisely.\n\n{ctx}"
)));
}
messages.push(ThreadMessage::user(prompt));
llm.complete(&messages, &[], &config).await
}));
}
// Collect results
let mut results = Vec::with_capacity(prompts.len());
let mut total_input = 0u64;
let mut total_output = 0u64;
for handle in handles {
match handle.await {
Ok(Ok(output)) => {
total_input += output.usage.input_tokens;
total_output += output.usage.output_tokens;
let text = match output.response {
LlmResponse::Text(t) => t,
LlmResponse::ActionCalls { content, .. }
| LlmResponse::Code { content, .. } => content.unwrap_or_default(),
};
results.push(MontyObject::String(text));
}
Ok(Err(e)) => {
results.push(MontyObject::String(format!("Error: {e}")));
}
Err(e) => {
results.push(MontyObject::String(format!("Error: task failed: {e}")));
}
}
}
recursive_tokens.input_tokens += total_input;
recursive_tokens.output_tokens += total_output;
ExtFunctionResult::Return(MontyObject::List(results))
}
// ── Helpers ─────────────────────────────────────────────────
fn extract_string_arg(
args: &[MontyObject],
kwargs: &[(MontyObject, MontyObject)],
name: &str,
position: usize,
) -> Option<String> {
// Check kwargs first
for (k, v) in kwargs {
if let MontyObject::String(key) = k
&& key == name
@@ -395,11 +681,9 @@ fn extract_string_arg(
return Some(monty_to_string(v));
}
}
// Then positional
args.get(position).map(monty_to_string)
}
/// Convert any MontyObject to a string representation.
fn monty_to_string(obj: &MontyObject) -> String {
match obj {
MontyObject::String(s) => s.clone(),
@@ -407,18 +691,19 @@ fn monty_to_string(obj: &MontyObject) -> String {
MontyObject::Bool(b) => b.to_string(),
MontyObject::Int(i) => i.to_string(),
MontyObject::Float(f) => f.to_string(),
other => serde_json::to_string(&monty_to_json(other)).unwrap_or_else(|_| format!("{other:?}")),
other => {
serde_json::to_string(&monty_to_json(other)).unwrap_or_else(|_| format!("{other:?}"))
}
}
}
// ── Dispatch result ─────────────────────────────────────────
// ── Dispatch ────────────────────────────────────────────────
enum DispatchResult {
Ok(ExtFunctionResult),
NeedApproval,
}
/// Dispatch an action call through lease + policy + effect executor.
#[allow(clippy::too_many_arguments)]
async fn dispatch_action(
action_name: &str,
@@ -433,7 +718,6 @@ async fn dispatch_action(
action_results: &mut Vec<ActionResult>,
events: &mut Vec<EventKind>,
) -> DispatchResult {
// Find lease
let lease = match leases.find_lease_for_action(thread.id, action_name).await {
Some(l) => l,
None => {
@@ -447,7 +731,6 @@ async fn dispatch_action(
}
};
// Find action definition and check policy
let action_def = effects
.available_actions(std::slice::from_ref(&lease))
.await
@@ -479,7 +762,6 @@ async fn dispatch_action(
}
}
// Consume lease use
if let Err(e) = leases.consume_use(lease.id).await {
return DispatchResult::Ok(ExtFunctionResult::Error(MontyException::new(
ExcType::RuntimeError,
@@ -487,7 +769,6 @@ async fn dispatch_action(
)));
}
// Execute the action
match effects
.execute_action(action_name, params, &lease, context)
.await
@@ -525,18 +806,8 @@ async fn dispatch_action(
}
}
/// Wrap Monty resume results with error conversion.
fn resume_monty<T: monty::ResourceTracker>(
result: Result<RunProgress<T>, MontyException>,
) -> Result<RunProgress<T>, EngineError> {
result.map_err(|e| EngineError::Effect {
reason: format!("Python execution error: {e}"),
})
}
// ── MontyObject ↔ JSON ──────────────────────────────────────
// ── MontyObject ↔ JSON conversion ───────────────────────────
/// Convert a MontyObject to serde_json::Value.
fn monty_to_json(obj: &MontyObject) -> serde_json::Value {
match obj {
MontyObject::None => serde_json::Value::Null,
@@ -564,14 +835,13 @@ fn monty_to_json(obj: &MontyObject) -> serde_json::Value {
MontyObject::Set(items) | MontyObject::FrozenSet(items) => {
serde_json::Value::Array(items.iter().map(monty_to_json).collect())
}
MontyObject::Bytes(b) => serde_json::Value::String(
b.iter().map(|byte| format!("{byte:02x}")).collect(),
),
MontyObject::Bytes(b) => {
serde_json::Value::String(b.iter().map(|byte| format!("{byte:02x}")).collect())
}
other => serde_json::Value::String(format!("{other:?}")),
}
}
/// Convert serde_json::Value to MontyObject.
fn json_to_monty(val: &serde_json::Value) -> MontyObject {
match val {
serde_json::Value::Null => MontyObject::None,
@@ -597,7 +867,6 @@ fn json_to_monty(val: &serde_json::Value) -> MontyObject {
}
}
/// Convert Monty function call args + kwargs to a JSON object.
fn monty_args_to_json(
args: &[MontyObject],
kwargs: &[(MontyObject, MontyObject)],