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}