Skip to main content

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}