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