adk_codeact_monty/runtime.rs
1//! [`MontyRuntime`] — a Python [`CodeRuntime`] backed by Pydantic Monty.
2//!
3//! Monty's iterative execution model is a near-perfect fit for the CodeAct seam:
4//! [`MontyRun::start`] runs a script until it calls an external function, hands
5//! the host a [`FunctionCall`] it can inspect, run a tool for, and resume — and
6//! the whole suspended continuation can be serialized to bytes with
7//! [`RunProgress::dump`] and restored later with [`RunProgress::load`]. That is
8//! exactly what [`PendingCall::dump`] / [`CodeRuntime::resume`] need to persist a
9//! paused run to session state and continue it on a later invocation.
10//!
11//! # The driver
12//!
13//! Tools are the only suspension the *agent* handles, so [`drive`] runs the
14//! interpreter forward until it reaches a tool call ([`RunStep::Call`]) or
15//! finishes ([`RunStep::Complete`]). The other Monty suspension points are
16//! resolved in-place by the runtime:
17//!
18//! - an **OS call** (filesystem, environment, clock) is serviced against the
19//! host's [`OsAccess`] policy and resumed immediately — OS calls are never
20//! tools and never pause the agent loop;
21//! - a **name lookup** for an undefined name raises `NameError`;
22//! - a blocked **`await`** on external futures is refused, steering the model
23//! toward synchronous tool calls.
24//!
25//! A Python exception that propagates to the top of a script is **not** a host
26//! error — it is the model's mistake. It surfaces as [`RunStep::Raised`] carrying
27//! Monty's traceback (and any parse/compile failure does too), which the agent
28//! feeds back verbatim for the model to fix. [`RuntimeError`] is reserved for
29//! genuine host failures (snapshot (de)serialization).
30//!
31//! # No shared state
32//!
33//! Because the CodeAct driver binds tool arguments centrally (it maps a call's
34//! positional/keyword arguments onto the tool schema for us), this runtime is
35//! entirely stateless: it converts a call's arguments to JSON and hands them up,
36//! and never needs to remember tool schemas between [`CodeRuntime::render_tools`]
37//! and the call boundary.
38//!
39//! # stdout
40//!
41//! Monty captures `print()` output per step (via [`PrintWriter::collect_string`],
42//! capped at [`DEFAULT_MAX_PRINT_COLLECT_BYTES`](adk_code::embedded_python::monty_types::DEFAULT_MAX_PRINT_COLLECT_BYTES)
43//! — exceeding it raises `MemoryError` in the script), which is attached to each
44//! [`RunStep`] so the agent can surface it back to the model.
45
46use std::sync::Arc;
47use std::time::Duration;
48
49use adk_agent::codeact::{
50 CodeRuntime, PendingCall, ResumeWith, RunStep, RuntimeCapabilities, RuntimeError,
51};
52use adk_code::embedded_python::monty::{FunctionCall, MontyRun, RunProgress};
53use adk_code::embedded_python::monty_types::{
54 CompileOptions, ExcType, ExtFunctionResult, LimitedTracker, MontyException, MontyObject,
55 NameLookupResult, PrintWriter, ResourceLimits,
56};
57use adk_code::embedded_python::{json_to_monty, monty_to_json};
58use adk_core::Tool;
59use serde_json::Value;
60
61use crate::os_access::{OsAccess, OsAccessBuilder, PathAccess};
62use crate::prompt::{MONTY_PROMPT, TOOL_DISPATCH_FN, tool_entry};
63
64/// The resource tracker every run is created with. `LimitedTracker` serializes
65/// cleanly (so it rides along inside a dumped continuation) and enforces the
66/// configured [`ResourceLimits`].
67type Tracker = LimitedTracker;
68
69/// A suspended/finished Monty run, parameterised on our tracker.
70type Progress = RunProgress<Tracker>;
71
72/// A Python [`CodeRuntime`] for the [`CodeActAgent`](adk_agent::codeact::CodeActAgent),
73/// backed by the Monty interpreter.
74///
75/// Build one with [`MontyRuntime::new`] for sensible defaults, or
76/// [`MontyRuntime::builder`] to set resource limits, grant OS access (mounted
77/// paths, an environment map, the host clock), or extend the language briefing.
78/// Hand the result to
79/// [`CodeActAgentBuilder::runtime`](adk_agent::codeact::CodeActAgentBuilder::runtime).
80///
81/// # Example
82///
83/// ```no_run
84/// use std::sync::Arc;
85/// use adk_codeact_monty::MontyRuntime;
86///
87/// let runtime = Arc::new(MontyRuntime::new());
88/// // CodeActAgent::builder().runtime(runtime)...
89/// ```
90pub struct MontyRuntime {
91 limits: ResourceLimits,
92 extra_prompt: Option<String>,
93 /// Host-controlled OS-access policy (mounted paths, environment, clock).
94 /// Shared into every paused tool call so a resumed run keeps the same
95 /// policy. See [`OsAccess`].
96 os: Arc<OsAccess>,
97}
98
99/// Conservative per-advance resource limits applied by default.
100///
101/// LLM-generated Python can contain an accidental infinite loop or a runaway
102/// allocation. Advancing the interpreter (`start`/`resume`) is synchronous, so
103/// an unbounded loop would block the calling task; these caps keep a single
104/// advance bounded. They apply *per advance* (time spent in your tools between
105/// steps does not count) and restart after deserialization, so a resumed run
106/// stays bounded too. Override any of them with the builder, or remove them
107/// entirely with [`MontyRuntimeBuilder::unlimited`].
108///
109/// Defaults: 5s wall-clock per advance, 256 MiB memory. Recursion keeps Monty's
110/// own default guard (1000 frames).
111fn default_resource_limits() -> ResourceLimits {
112 ResourceLimits::new().max_duration(Duration::from_secs(5)).max_memory(256 * 1024 * 1024)
113}
114
115impl MontyRuntime {
116 /// Create a runtime with conservative default resource limits suitable for
117 /// untrusted, LLM-generated code: 5s wall-clock per advance, 256 MiB
118 /// memory, and Monty's recursion guard.
119 ///
120 /// Relax or tighten these with [`MontyRuntime::builder`]; remove them
121 /// entirely (trusted scripts only) with
122 /// [`MontyRuntimeBuilder::unlimited`].
123 #[must_use]
124 pub fn new() -> Self {
125 Self::builder().build()
126 }
127
128 /// Start building a runtime with custom limits or prompt additions.
129 #[must_use]
130 pub fn builder() -> MontyRuntimeBuilder {
131 MontyRuntimeBuilder::new()
132 }
133
134 /// A fresh tracker for a new run, carrying the configured limits.
135 fn tracker(&self) -> Tracker {
136 LimitedTracker::new(self.limits.clone())
137 }
138}
139
140impl Default for MontyRuntime {
141 fn default() -> Self {
142 Self::new()
143 }
144}
145
146/// Builder for [`MontyRuntime`].
147pub struct MontyRuntimeBuilder {
148 limits: ResourceLimits,
149 extra_prompt: Option<String>,
150 os: OsAccessBuilder,
151}
152
153impl MontyRuntimeBuilder {
154 /// Create a builder seeded with the conservative default limits (5s
155 /// wall-clock per advance, 256 MiB memory) and a fully sandboxed OS policy
156 /// (no filesystem access, empty environment, host clock enabled).
157 #[must_use]
158 pub fn new() -> Self {
159 Self { limits: default_resource_limits(), extra_prompt: None, os: OsAccessBuilder::new() }
160 }
161
162 /// Replace the full set of resource limits.
163 #[must_use]
164 pub fn resource_limits(mut self, limits: ResourceLimits) -> Self {
165 self.limits = limits;
166 self
167 }
168
169 /// Remove all resource caps except Monty's built-in recursion guard.
170 ///
171 /// Use this only for *trusted* scripts. LLM-generated code should keep the
172 /// default time/memory caps so an accidental infinite loop or runaway
173 /// allocation cannot block the calling task. Equivalent to
174 /// `resource_limits(ResourceLimits::new())`.
175 ///
176 /// # Example
177 ///
178 /// ```
179 /// use adk_codeact_monty::MontyRuntime;
180 ///
181 /// let runtime = MontyRuntime::builder().unlimited().build();
182 /// # let _ = runtime;
183 /// ```
184 #[must_use]
185 pub fn unlimited(mut self) -> Self {
186 self.limits = ResourceLimits::new();
187 self
188 }
189
190 /// Cap wall-clock execution time for each interpreter step.
191 ///
192 /// The limit applies per `start`/`resume` advance (time spent in your tools
193 /// between steps does not count), and restarts after deserialization.
194 #[must_use]
195 pub fn max_duration(mut self, duration: Duration) -> Self {
196 self.limits.max_duration = Some(duration);
197 self
198 }
199
200 /// Cap approximate heap memory (bytes) a script may use.
201 #[must_use]
202 pub fn max_memory(mut self, bytes: usize) -> Self {
203 self.limits.max_memory = Some(bytes);
204 self
205 }
206
207 /// Append extra text to the language briefing in the system prompt (e.g.
208 /// domain conventions, additional usage rules).
209 #[must_use]
210 pub fn additional_prompt(mut self, text: impl Into<String>) -> Self {
211 self.extra_prompt = Some(text.into());
212 self
213 }
214
215 /// Replace the whole OS-access policy.
216 ///
217 /// Use this when you have built an [`OsAccess`] separately; otherwise reach
218 /// for the per-aspect shortcuts [`Self::allow_path`], [`Self::environ`],
219 /// [`Self::environ_var`], and [`Self::system_clock`].
220 #[must_use]
221 pub fn os_access(mut self, access: OsAccess) -> Self {
222 self.os = access.into_builder();
223 self
224 }
225
226 /// Make a host directory available to scripts at `virtual_path`, read-only
227 /// or read-write.
228 ///
229 /// Scripts reach it through `pathlib.Path` against `virtual_path` (e.g.
230 /// `/data`); Monty enforces the mount boundary so a script can never escape
231 /// it. By default no paths are accessible. See
232 /// [`OsAccessBuilder::allow_path`].
233 ///
234 /// # Example
235 ///
236 /// ```no_run
237 /// use adk_codeact_monty::{MontyRuntime, PathAccess};
238 ///
239 /// let runtime = MontyRuntime::builder()
240 /// .allow_path("/data", "/srv/agent/data", PathAccess::ReadOnly)
241 /// .build();
242 /// # let _ = runtime;
243 /// ```
244 #[must_use]
245 pub fn allow_path(
246 mut self,
247 virtual_path: impl Into<String>,
248 host_path: impl Into<std::path::PathBuf>,
249 access: PathAccess,
250 ) -> Self {
251 self.os = self.os.allow_path(virtual_path, host_path, access);
252 self
253 }
254
255 /// Replace the environment map exposed to scripts via `os.getenv` /
256 /// `os.environ`. Empty by default — the host process environment is never
257 /// exposed implicitly.
258 #[must_use]
259 pub fn environ<K, V>(mut self, vars: impl IntoIterator<Item = (K, V)>) -> Self
260 where
261 K: Into<String>,
262 V: Into<String>,
263 {
264 self.os = self.os.environ(vars);
265 self
266 }
267
268 /// Add or overwrite a single environment variable visible to scripts.
269 #[must_use]
270 pub fn environ_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
271 self.os = self.os.environ_var(key, value);
272 self
273 }
274
275 /// Enable or disable host-clock access (`date.today()` / `datetime.now()`).
276 /// Enabled by default.
277 #[must_use]
278 pub fn system_clock(mut self, enabled: bool) -> Self {
279 self.os = self.os.system_clock(enabled);
280 self
281 }
282
283 /// Finish building the runtime.
284 #[must_use]
285 pub fn build(self) -> MontyRuntime {
286 MontyRuntime {
287 limits: self.limits,
288 extra_prompt: self.extra_prompt,
289 os: Arc::new(self.os.build()),
290 }
291 }
292}
293
294impl Default for MontyRuntimeBuilder {
295 fn default() -> Self {
296 Self::new()
297 }
298}
299
300impl CodeRuntime for MontyRuntime {
301 fn start(&self, script: &str, script_name: &str) -> Result<RunStep, RuntimeError> {
302 // A parse/compile failure is the model's mistake: surface it as a
303 // `RunStep::Raised` (fed back to the model), never a host `RuntimeError`.
304 let run = match MontyRun::new(
305 script.to_string(),
306 script_name,
307 Vec::new(),
308 CompileOptions::default(),
309 ) {
310 Ok(run) => run,
311 Err(exc) => return Ok(RunStep::raised(render_exception(&exc))),
312 };
313 let mut stdout = String::new();
314 match run.start(Vec::new(), self.tracker(), PrintWriter::collect_string(&mut stdout)) {
315 Ok(progress) => drive(progress, stdout, &self.os),
316 // An exception raised during the first stretch of execution is a
317 // script error: surface it as a Raised traceback, not a host error.
318 Err(exc) => Ok(RunStep::raised(render_exception(&exc)).with_stdout(stdout)),
319 }
320 }
321
322 fn resume(&self, snapshot: &[u8], with: ResumeWith) -> Result<RunStep, RuntimeError> {
323 let progress =
324 Progress::load(snapshot).map_err(|err| RuntimeError::Snapshot(err.to_string()))?;
325 let call = progress.into_function_call().ok_or_else(|| {
326 RuntimeError::Snapshot("snapshot is not a paused external function call".to_string())
327 })?;
328 resume_call(call, with, &self.os)
329 }
330
331 fn capabilities(&self) -> RuntimeCapabilities {
332 let mut prompt = MONTY_PROMPT.to_string();
333 prompt.push_str("\n\n");
334 prompt.push_str(&self.os.prompt_section());
335 if let Some(extra) = &self.extra_prompt {
336 prompt.push_str("\n\n");
337 prompt.push_str(extra);
338 }
339 // Monty can serialize a paused continuation, so HITL confirmation and
340 // long-running tool deferral are both supported.
341 RuntimeCapabilities::new(true, prompt)
342 }
343
344 fn render_tools(&self, tools: &[Arc<dyn Tool>]) -> String {
345 // Pure rendering: no state is kept. Argument binding is the driver's job.
346 let mut entries = String::new();
347 for tool in tools {
348 // Built-in (server-side) tools cannot be called from a script.
349 if tool.is_builtin() {
350 continue;
351 }
352 entries.push_str(&tool_entry(tool.as_ref()));
353 }
354 if entries.trim().is_empty() {
355 return String::new();
356 }
357 // Every tool is invoked the same way: there is no bare-callable form.
358 format!(
359 "The following tools are available. Invoke each one with the built-in \
360 `{TOOL_DISPATCH_FN}` function — the first argument is the tool name and \
361 the rest are passed by keyword; a tool is never callable as a bare name. \
362 Each returns a JSON-compatible value.\n\n```python\n{entries}```"
363 )
364 }
365}
366
367/// Run the interpreter forward to the next *agent-relevant* stop, carrying any
368/// captured `stdout` along.
369///
370/// Tool calls and completion are returned to the agent; OS calls, name lookups,
371/// and blocked futures are resolved in-place (see the module docs) so the loop
372/// continues until a tool call or completion is reached. A Python exception that
373/// propagates out becomes [`RunStep::Raised`].
374///
375/// OS calls (filesystem, environment, clock) are serviced in-place against the
376/// [`OsAccess`] policy and resumed immediately — they are never tools and never
377/// pause the agent loop. A fresh
378/// [`MountTable`](adk_code::embedded_python::monty_fs::MountTable) is built
379/// once per drive so concurrent runs of the same runtime never share mount
380/// state.
381fn drive(
382 mut progress: Progress,
383 mut stdout: String,
384 os: &Arc<OsAccess>,
385) -> Result<RunStep, RuntimeError> {
386 let mut mounts = os.build_mount_table()?;
387 loop {
388 match progress {
389 RunProgress::Complete(value) => {
390 return Ok(RunStep::complete(monty_to_json(&value)).with_stdout(stdout));
391 }
392 RunProgress::FunctionCall(call) => match resolve_dispatch(&call) {
393 Ok((name, keyword)) => {
394 let pending = MontyPendingCall::from_call(call, name, keyword, os.clone());
395 return Ok(RunStep::call(Box::new(pending)).with_stdout(stdout));
396 }
397 // Not a well-formed `call_tool(...)` dispatch: raise a corrective
398 // error into the script so the model can fix its call. There is
399 // exactly one way to call a tool — no lenient bare-name fallback.
400 Err(message) => {
401 progress = match call.resume(
402 ExtFunctionResult::Error(monty_error(&message)),
403 PrintWriter::collect_string(&mut stdout),
404 ) {
405 Ok(next) => next,
406 Err(exc) => {
407 return Ok(RunStep::raised(render_exception(&exc)).with_stdout(stdout));
408 }
409 };
410 }
411 },
412 RunProgress::OsCall(call) => {
413 // Resolve filesystem/env/clock access in-place against the
414 // host policy and resume immediately — never surfaced as a tool.
415 // `resume_with` hands the call over by value so a write's
416 // payload moves into the mount backend without a copy.
417 progress = match call
418 .resume_with(PrintWriter::collect_string(&mut stdout), |call| {
419 os.resolve(call, &mut mounts)
420 }) {
421 Ok(next) => next,
422 Err(exc) => {
423 return Ok(RunStep::raised(render_exception(&exc)).with_stdout(stdout));
424 }
425 };
426 }
427 RunProgress::NameLookup(lookup) => {
428 // The runtime exposes tools only as *called* functions; a bare
429 // reference to an unknown name is a genuine NameError.
430 progress = match lookup
431 .resume(NameLookupResult::Undefined, PrintWriter::collect_string(&mut stdout))
432 {
433 Ok(next) => next,
434 Err(exc) => {
435 return Ok(RunStep::raised(render_exception(&exc)).with_stdout(stdout));
436 }
437 };
438 }
439 RunProgress::ResolveFutures(futures) => {
440 let denied: Vec<(u32, ExtFunctionResult)> = futures
441 .pending_call_ids()
442 .iter()
443 .map(|id| {
444 (
445 *id,
446 ExtFunctionResult::Error(monty_error(
447 "asynchronous external calls are not supported; call tools synchronously, without `await`",
448 )),
449 )
450 })
451 .collect();
452 progress = match futures.resume(denied, PrintWriter::collect_string(&mut stdout)) {
453 Ok(next) => next,
454 Err(exc) => {
455 return Ok(RunStep::raised(render_exception(&exc)).with_stdout(stdout));
456 }
457 };
458 }
459 }
460 }
461}
462
463/// A paused tool call: the agent inspects it, runs the tool, then resumes (or
464/// dumps it to session state to resume on a later invocation).
465struct MontyPendingCall {
466 name: String,
467 keyword: Vec<(String, Value)>,
468 call_id: u64,
469 /// Always the [`RunProgress::FunctionCall`] variant for this call.
470 progress: Progress,
471 /// OS-access policy to apply when the resumed run hits an OS call. Carried
472 /// here so an in-process resume keeps the same policy without a runtime
473 /// reference.
474 os: Arc<OsAccess>,
475}
476
477impl MontyPendingCall {
478 /// Build a pending call from a validated `call_tool(...)` dispatch: the real
479 /// tool `name` and the `keyword` arguments (the entries of the call's
480 /// arguments dict), both already extracted by [`resolve_dispatch`].
481 ///
482 /// Monty always passes arguments as a single named dict, so the seam's
483 /// positional slice is always empty and the driver binds the keyword entries
484 /// onto the tool's parameters by name — exactly, with no positional
485 /// inference.
486 fn from_call(
487 call: FunctionCall<Tracker>,
488 name: String,
489 keyword: Vec<(String, Value)>,
490 os: Arc<OsAccess>,
491 ) -> Self {
492 let call_id = u64::from(call.call_id);
493 Self { name, keyword, call_id, progress: RunProgress::FunctionCall(call), os }
494 }
495}
496
497/// Resolve a Monty function call against the single tool-calling convention:
498/// `call_tool("<tool-name>", {"arg": value, ...})`.
499///
500/// There is exactly one way to call a tool, so anything else is the model's
501/// mistake and is reported as an `Err(message)` describing the correct form
502/// (which the caller raises back into the script). Rejected, with no silent
503/// coercion: a bare call to some other name; a missing/non-string tool name;
504/// keyword arguments to `call_tool` itself; more than the name and one dict; a
505/// non-dict arguments value; or an arguments dict with a non-string key.
506///
507/// On success returns the real tool name and the dict's entries as exact
508/// name→value pairs.
509fn resolve_dispatch(
510 call: &FunctionCall<Tracker>,
511) -> Result<(String, Vec<(String, Value)>), String> {
512 if call.function_name != TOOL_DISPATCH_FN {
513 return Err(format!(
514 "'{}' is not defined. Call tools only via {TOOL_DISPATCH_FN}(\"<tool-name>\", {{...}}).",
515 call.function_name
516 ));
517 }
518 let Some(MontyObject::String(name)) = call.args.first() else {
519 return Err(format!(
520 "{TOOL_DISPATCH_FN}(...) needs the tool name as the first positional string argument, \
521 e.g. {TOOL_DISPATCH_FN}(\"my_tool\", {{\"arg\": value}})."
522 ));
523 };
524 let name = name.clone();
525
526 if !call.kwargs.is_empty() {
527 return Err(format!(
528 "{TOOL_DISPATCH_FN}(...) takes the tool name and a single arguments dict; put tool \
529 arguments inside the dict, not as keyword arguments: \
530 {TOOL_DISPATCH_FN}(\"{name}\", {{\"arg\": value}})."
531 ));
532 }
533 if call.args.len() > 2 {
534 return Err(format!(
535 "{TOOL_DISPATCH_FN}(...) takes exactly the tool name and one arguments dict: \
536 {TOOL_DISPATCH_FN}(\"{name}\", {{\"arg\": value}})."
537 ));
538 }
539
540 let keyword = match call.args.get(1) {
541 None => Vec::new(),
542 Some(MontyObject::Dict(pairs)) => {
543 let mut keyword = Vec::with_capacity(pairs.len());
544 for (key, value) in pairs {
545 let MontyObject::String(key) = key else {
546 return Err(format!(
547 "{TOOL_DISPATCH_FN}(\"{name}\", ...) argument keys must be strings; \
548 pass arguments as {{\"arg\": value}}."
549 ));
550 };
551 keyword.push((key.clone(), monty_to_json(value)));
552 }
553 keyword
554 }
555 Some(_) => {
556 return Err(format!(
557 "{TOOL_DISPATCH_FN}(\"{name}\", ...) needs a single arguments dict, \
558 e.g. {TOOL_DISPATCH_FN}(\"{name}\", {{\"arg\": value}})."
559 ));
560 }
561 };
562
563 Ok((name, keyword))
564}
565
566impl PendingCall for MontyPendingCall {
567 fn function_name(&self) -> &str {
568 &self.name
569 }
570
571 fn positional_args(&self) -> &[Value] {
572 // Monty always passes arguments as a named dict; there are no positionals.
573 &[]
574 }
575
576 fn keyword_args(&self) -> &[(String, Value)] {
577 &self.keyword
578 }
579
580 fn call_id(&self) -> u64 {
581 self.call_id
582 }
583
584 fn dump(&self) -> Result<Vec<u8>, RuntimeError> {
585 self.progress.dump().map_err(|err| RuntimeError::Snapshot(err.to_string()))
586 }
587
588 fn resume(self: Box<Self>, with: ResumeWith) -> Result<RunStep, RuntimeError> {
589 let os = self.os.clone();
590 let call = self
591 .progress
592 .into_function_call()
593 .expect("MontyPendingCall always wraps a function call");
594 resume_call(call, with, &os)
595 }
596}
597
598/// Feed a tool result (or a raised error) back into a paused [`FunctionCall`]
599/// and drive the interpreter onward, capturing any `print` output.
600fn resume_call(
601 call: FunctionCall<Tracker>,
602 with: ResumeWith,
603 os: &Arc<OsAccess>,
604) -> Result<RunStep, RuntimeError> {
605 let result = match with {
606 ResumeWith::Value(value) => ExtFunctionResult::Return(json_to_monty(value)),
607 // Raise the framework's error message into the script as an exception the
608 // model's code can `try`/`except`, exactly like a real tool failure.
609 ResumeWith::Raise(message) => ExtFunctionResult::Error(monty_error(&message)),
610 };
611 let mut stdout = String::new();
612 match call.resume(result, PrintWriter::collect_string(&mut stdout)) {
613 Ok(progress) => drive(progress, stdout, os),
614 Err(exc) => Ok(RunStep::raised(render_exception(&exc)).with_stdout(stdout)),
615 }
616}
617
618/// Build a Monty exception carrying `message`, raised into a script at a call
619/// site. Uses `RuntimeError` as the Python type — the message is what matters to
620/// the model, and the framework's error strings are already self-describing.
621fn monty_error(message: &str) -> MontyException {
622 MontyException::new(ExcType::RuntimeError, Some(message.to_string()))
623}
624
625/// Render a Monty exception for the model: a CPython-style traceback plus the
626/// `Type: message` line. Fed back verbatim as the opaque error string.
627fn render_exception(exc: &MontyException) -> String {
628 exc.to_string()
629}