| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, | |
| * software distributed under the License is distributed on an | |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| * KIND, either express or implied. See the License for the | |
| * specific language governing permissions and limitations | |
| * under the License. | |
| */ | |
| //! Optional Python Code Mode interpreter. Each cell owns a disposable Monty | |
| //! process; only the Host can admit tools, publish output or commit JSON state. | |
| mod helpers; | |
| mod protocol; | |
| mod value; | |
| pub mod worker; | |
| use futures_util::{StreamExt, future::BoxFuture, stream::FuturesUnordered}; | |
| use maka_agent_facts::tools::{ToolError, ToolExecutor}; | |
| use maka_code_runtime::{ | |
| CellAbort, CellContext, CellDiagnostic, CellLimits, CellResult, ToolScope, complete, | |
| }; | |
| use protocol::{Reply, Request, Start}; | |
| use serde_json::Value; | |
| use std::{ | |
| collections::{BTreeMap, HashSet}, | |
| path::PathBuf, | |
| process::Stdio, | |
| sync::Arc, | |
| time::Instant, | |
| }; | |
| use tokio::{ | |
| io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, | |
| process::{Child, ChildStdin, ChildStdout}, | |
| runtime::Handle, | |
| }; | |
| use tokio_util::sync::CancellationToken; | |
| #[derive(Clone)] | |
| pub struct CodeExecutor { | |
| limits: CellLimits, | |
| worker: PathBuf, | |
| } | |
| impl CodeExecutor { | |
| pub fn new(limits: CellLimits) -> Result<Self, CellAbort> { | |
| let current = std::env::current_exe().map_err(internal)?; | |
| let worker = current | |
| .parent() | |
| .ok_or_else(|| internal("executable has no parent directory"))? | |
| .join(if cfg!(windows) { | |
| "maka-python-worker.exe" | |
| } else { | |
| "maka-python-worker" | |
| }); | |
| Self::with_worker(limits, worker) | |
| } | |
| /// Embeddings supply their packaged worker explicitly. The model never | |
| /// chooses a command, environment or interpreter executable. | |
| pub fn with_worker(limits: CellLimits, worker: PathBuf) -> Result<Self, CellAbort> { | |
| if limits.timeout.is_zero() | |
| || limits.timeout.as_millis() > u64::MAX as u128 | |
| || Instant::now().checked_add(limits.timeout).is_none() | |
| || limits.heap_bytes < 8 * 1024 * 1024 | |
| { | |
| return Err(internal("invalid cell limits")); | |
| } | |
| Ok(Self { limits, worker }) | |
| } | |
| pub fn limits(&self) -> &CellLimits { | |
| &self.limits | |
| } | |
| pub async fn execute_module( | |
| &self, | |
| source: String, | |
| tools: Arc<dyn ToolExecutor>, | |
| cancellation: CancellationToken, | |
| context: CellContext, | |
| ) -> Result<CellResult, CellAbort> { | |
| if cancellation.is_cancelled() { | |
| return Err(CellAbort::Cancelled); | |
| } | |
| let cancellation = cancellation.child_token(); | |
| let _cancel_on_drop = cancellation.clone().drop_guard(); | |
| let executor = self.clone(); | |
| // The owner task survives a dropped observer until child reaping and | |
| // every admitted Host effect have settled. | |
| tokio::spawn(async move { executor.run(source, tools, cancellation, context).await }) | |
| .await | |
| .map_err(internal)? | |
| } | |
| async fn run( | |
| &self, | |
| source: String, | |
| tools: Arc<dyn ToolExecutor>, | |
| cancellation: CancellationToken, | |
| context: CellContext, | |
| ) -> Result<CellResult, CellAbort> { | |
| let names = tools.names(); | |
| let mut unique = HashSet::new(); | |
| if names | |
| .iter() | |
| .any(|name| !unique.insert(tool_identifier(name))) | |
| { | |
| return Ok(complete( | |
| Err(helpers::error( | |
| "tool names collide after Python normalization", | |
| )), | |
| vec![], | |
| )); | |
| } | |
| let scope = ToolScope::new( | |
| context.clone(), | |
| tools, | |
| cancellation.child_token(), | |
| Handle::current(), | |
| ); | |
| let mut command = tokio::process::Command::new(&self.worker); | |
| #[cfg(windows)] | |
| command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW: worker only uses pipes. | |
| let mut child = command | |
| .args([ | |
| self.limits.heap_bytes.to_string(), | |
| self.limits.timeout.as_millis().to_string(), | |
| ]) | |
| .env_clear() | |
| .stdin(Stdio::piped()) | |
| .stdout(Stdio::piped()) | |
| .stderr(Stdio::null()) | |
| .kill_on_drop(true) | |
| .spawn() | |
| .map_err(|error| { | |
| internal(format!( | |
| "cannot start bundled Python worker {}: {error}", | |
| self.worker.display() | |
| )) | |
| })?; | |
| let mut input = child | |
| .stdin | |
| .take() | |
| .ok_or_else(|| internal("missing worker input"))?; | |
| let mut output = BufReader::new( | |
| child | |
| .stdout | |
| .take() | |
| .ok_or_else(|| internal("missing worker output"))?, | |
| ); | |
| let start = Start { | |
| source, | |
| names, | |
| metadata: context.metadata().to_vec(), | |
| }; | |
| let result = tokio::select! { | |
| biased; | |
| _=cancellation.cancelled()=>Err(CellAbort::Cancelled), | |
| result=drive(&mut input,&mut output,start,&scope,&context,&self.limits)=>result, | |
| }; | |
| scope.cancel(); | |
| drop(input); | |
| let status = reap(&mut child).await; | |
| scope.settle().await; | |
| if let Some(error) = context.failure() { | |
| return Err(CellAbort::Tool(error)); | |
| } | |
| let status = status?; | |
| if cancellation.is_cancelled() { | |
| return Err(CellAbort::Cancelled); | |
| } | |
| if status.code() == Some(monty_types::OOM_EXIT_CODE) { | |
| return Ok(complete( | |
| Err(CellDiagnostic::limit("Python worker memory limit exceeded")), | |
| scope.calls(), | |
| )); | |
| } | |
| Ok(complete(result?, scope.calls())) | |
| } | |
| } | |
| type Pending = FuturesUnordered<BoxFuture<'static, (u32, Result<Value, CellDiagnostic>)>>; | |
| async fn drive( | |
| input: &mut ChildStdin, | |
| output: &mut BufReader<ChildStdout>, | |
| start: Start, | |
| scope: &Arc<ToolScope>, | |
| context: &CellContext, | |
| limits: &CellLimits, | |
| ) -> Result<Result<Value, CellDiagnostic>, CellAbort> { | |
| send(input, &start).await?; | |
| // Loading the trusted executable and initializing transport precede user | |
| // execution, just as V8 construction precedes its execution clock. The | |
| // owning cancellation token remains active through this handshake. | |
| if !matches!( | |
| receive(output, limits.heap_bytes.saturating_add(4 * 1024 * 1024)).await?, | |
| Request::Ready | |
| ) { | |
| return Err(internal("Python worker omitted its ready frame")); | |
| } | |
| let mut remaining = limits.timeout; | |
| let mut pending = Pending::new(); | |
| let mut ready = BTreeMap::new(); | |
| let mut admitted = HashSet::new(); | |
| loop { | |
| let started = Instant::now(); | |
| let request = tokio::time::timeout( | |
| remaining, | |
| receive(output, limits.heap_bytes.saturating_add(4 * 1024 * 1024)), | |
| ) | |
| .await; | |
| remaining = remaining.saturating_sub(started.elapsed()); | |
| let request = match request { | |
| Ok(request) => request?, | |
| Err(_) => { | |
| return Ok(Err(CellDiagnostic::limit( | |
| "Python execution budget exceeded", | |
| ))); | |
| } | |
| }; | |
| let reply = match request { | |
| Request::Ready => return Err(internal("duplicate Python worker ready frame")), | |
| Request::Complete { error } => { | |
| return Ok(match error { | |
| Some(error) => Err(error), | |
| None => Ok(Value::Null), | |
| }); | |
| } | |
| Request::Call { | |
| id, | |
| name, | |
| args, | |
| kwargs, | |
| } => { | |
| if let Some(name) = name.strip_prefix("tool:") { | |
| let input = match (args.as_slice(), kwargs.is_empty()) { | |
| ([value], true) => Ok(value.clone()), | |
| ([], false) => Ok(Value::Object(kwargs)), | |
| ([], true) => Ok(serde_json::json!({})), | |
| _ => Err(helpers::error( | |
| "tool expects one JSON argument or keyword arguments", | |
| )), | |
| }; | |
| match input.and_then(|input| { | |
| protocol::validate_json(&input)?; | |
| scope.start(name.into(), input) | |
| }) { | |
| Ok(receiver) => { | |
| if !admitted.insert(id) { | |
| return Err(internal("duplicate Python call identity")); | |
| } | |
| pending.push(Box::pin(async move { | |
| ( | |
| id, | |
| receiver | |
| .await | |
| .unwrap_or_else(|_| { | |
| Err(helpers::error("tool outcome unavailable")) | |
| }) | |
| .and_then(|value| { | |
| protocol::validate_json(&value)?; | |
| Ok(value) | |
| }), | |
| ) | |
| })); | |
| Reply::Pending | |
| } | |
| Err(error) => Reply::Error { error }, | |
| } | |
| } else if let Some(name) = name.strip_prefix("helper:") { | |
| match helpers::invoke(name, args, kwargs, context).and_then(|value| { | |
| protocol::validate_json(&value)?; | |
| Ok(value) | |
| }) { | |
| Ok(_) if name == "exit" => return Ok(Ok(Value::Null)), | |
| Ok(_) if name == "yield_control" => { | |
| admitted.insert(id); | |
| pending.push(Box::pin(async move { | |
| tokio::task::yield_now().await; | |
| (id, Ok(Value::Null)) | |
| })); | |
| Reply::Pending | |
| } | |
| Ok(value) => Reply::Return { value }, | |
| Err(error) => Reply::Error { error }, | |
| } | |
| } else { | |
| Reply::Error { | |
| error: helpers::error("unknown external function"), | |
| } | |
| } | |
| } | |
| Request::Sleep { | |
| id, | |
| duration, | |
| asynchronous, | |
| } => { | |
| if Instant::now().checked_add(duration).is_none() { | |
| Reply::Error { | |
| error: helpers::error("sleep duration is too large"), | |
| } | |
| } else if asynchronous { | |
| admitted.insert(id); | |
| pending.push(Box::pin(async move { | |
| tokio::time::sleep(duration).await; | |
| (id, Ok(Value::Null)) | |
| })); | |
| Reply::Pending | |
| } else { | |
| tokio::time::sleep(duration).await; | |
| Reply::Return { value: Value::Null } | |
| } | |
| } | |
| Request::Resolve { ids } => { | |
| if ids.is_empty() || ids.iter().any(|id| !admitted.contains(id)) { | |
| return Err(internal("unknown pending Python call")); | |
| } | |
| while !ids.iter().any(|id| ready.contains_key(id)) { | |
| let (id, result) = pending | |
| .next() | |
| .await | |
| .ok_or_else(|| internal("Python futures lost their owner"))?; | |
| ready.insert(id, result); | |
| } | |
| let results = ids | |
| .into_iter() | |
| .filter_map(|id| { | |
| ready.remove(&id).map(|result| { | |
| admitted.remove(&id); | |
| (id, result) | |
| }) | |
| }) | |
| .collect(); | |
| Reply::Resolved { results } | |
| } | |
| }; | |
| send(input, &reply).await?; | |
| } | |
| } | |
| async fn send(input: &mut ChildStdin, message: &impl serde::Serialize) -> Result<(), CellAbort> { | |
| let mut bytes = serde_json::to_vec(message).map_err(internal)?; | |
| bytes.push(b'\n'); | |
| input.write_all(&bytes).await.map_err(internal)?; | |
| input.flush().await.map_err(internal) | |
| } | |
| async fn receive( | |
| output: &mut BufReader<ChildStdout>, | |
| max_bytes: usize, | |
| ) -> Result<Request, CellAbort> { | |
| let mut line = Vec::new(); | |
| loop { | |
| let bytes = output.fill_buf().await.map_err(internal)?; | |
| if bytes.is_empty() { | |
| return Err(internal("Python worker ended without a terminal frame")); | |
| } | |
| let end = bytes | |
| .iter() | |
| .position(|byte| *byte == b'\n') | |
| .map(|index| index + 1); | |
| let count = end.unwrap_or(bytes.len()); | |
| if line.len().saturating_add(count) > max_bytes { | |
| return Err(internal("Python worker frame exceeds its memory budget")); | |
| } | |
| line.extend_from_slice(&bytes[..count]); | |
| output.consume(count); | |
| if end.is_some() { | |
| return protocol::decode(&line).map_err(internal); | |
| } | |
| } | |
| } | |
| async fn reap(child: &mut Child) -> Result<std::process::ExitStatus, CellAbort> { | |
| let cleanup = |error| { | |
| CellAbort::Tool(ToolError::CleanupUnconfirmed(format!( | |
| "Python worker cleanup: {error}" | |
| ))) | |
| }; | |
| if let Some(status) = child.try_wait().map_err(cleanup)? { | |
| return Ok(status); | |
| } | |
| child.start_kill().map_err(cleanup)?; | |
| child.wait().await.map_err(cleanup) | |
| } | |
| fn internal(error: impl std::fmt::Display) -> CellAbort { | |
| CellAbort::Internal(error.to_string()) | |
| } | |
| pub fn tool_identifier(name: &str) -> String { | |
| let mut result: String = name | |
| .chars() | |
| .enumerate() | |
| .map(|(index, ch)| { | |
| if ch == '_' || ch.is_ascii_alphabetic() || (index > 0 && ch.is_ascii_digit()) { | |
| ch | |
| } else { | |
| '_' | |
| } | |
| }) | |
| .collect(); | |
| if result.is_empty() { | |
| result.push('_'); | |
| } | |
| // Keywords cannot be attributes in Python source, even after a dot. | |
| if [ | |
| "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", | |
| "continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global", | |
| "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", | |
| "try", "while", "with", "yield", | |
| ] | |
| .contains(&result.as_str()) | |
| { | |
| result.push('_'); | |
| } | |
| result | |
| } |