blob: acf6554ba50349e13f8cdb61b3306424f1d6c25e [file]
/*
* 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
}