blob: 9cc51676c2d191300fec7ffbf43d7cb78e95f49f [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.
*/
mod bridge;
mod evaluate;
mod execution_budget;
mod module;
pub mod plugin;
pub mod repl;
use bridge::maka_code;
use deno_core::{JsRuntime, RuntimeOptions, v8};
use evaluate::evaluate;
use execution_budget::ExecutionBudget;
use maka_agent_facts::tools::ToolExecutor;
use maka_code_runtime::complete;
use maka_code_runtime::{CellAbort, CellDiagnostic, CellDiagnosticKind, CellResult};
use maka_code_runtime::{CellContext, CellLimits, CellOutput, CellStore, ToolMetadata, ToolScope};
use std::sync::{Arc, Mutex, Once};
use tokio::runtime::Handle;
use tokio_util::sync::CancellationToken;
/// Fresh isolates off the host's async worker threads, each retaining ownership
/// of admitted Rust tools until they have finished cleanup.
#[derive(Clone)]
pub struct CodeExecutor {
limits: CellLimits,
}
impl CodeExecutor {
pub fn new(limits: CellLimits) -> Result<Self, CellAbort> {
if limits.timeout.is_zero()
|| std::time::Instant::now()
.checked_add(limits.timeout)
.is_none()
|| limits.heap_bytes < 8 * 1024 * 1024
{
return Err(CellAbort::Internal("invalid cell limits".into()));
}
Ok(Self { limits })
}
pub async fn execute(
&self,
source: String,
tools: Arc<dyn ToolExecutor>,
cancellation: CancellationToken,
) -> Result<CellResult, CellAbort> {
let metadata = tools
.names()
.into_iter()
.map(|name| ToolMetadata {
name,
description: String::new(),
})
.collect();
let context = CellContext::new(CellStore::default(), metadata);
self.execute_with_context(source, tools, cancellation, context)
.await
}
pub fn limits(&self) -> &CellLimits {
&self.limits
}
pub async fn execute_with_context(
&self,
source: String,
tools: Arc<dyn ToolExecutor>,
cancellation: CancellationToken,
context: CellContext,
) -> Result<CellResult, CellAbort> {
self.execute_source(source, tools, cancellation, context, false)
.await
}
/// Model-facing Code Mode uses an async ES module, without a return value.
pub async fn execute_module(
&self,
source: String,
tools: Arc<dyn ToolExecutor>,
cancellation: CancellationToken,
context: CellContext,
) -> Result<CellResult, CellAbort> {
self.execute_source(source, tools, cancellation, context, true)
.await
}
async fn execute_source(
&self,
source: String,
tools: Arc<dyn ToolExecutor>,
cancellation: CancellationToken,
context: CellContext,
module: bool,
) -> Result<CellResult, CellAbort> {
if cancellation.is_cancelled() {
return Err(CellAbort::Cancelled);
}
let cancellation = cancellation.child_token();
// Dropping the caller's future cancels the worker; it does not detach
// execution without cleanup.
let _cancel_on_drop = cancellation.clone().drop_guard();
let handle = Handle::current();
let limits = self.limits.clone();
let (reply, result) = tokio::sync::oneshot::channel();
// A cell can await another cell through a Host tool. Holding a shared
// blocking-pool worker across that wait can starve its own child, so
// each independent isolate owns its thread through physical cleanup.
std::thread::Builder::new()
.name("maka-code-js".into())
.spawn(move || {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
// deno_core's unsynchronized op scheduler requires a
// current-thread runtime. Host effects stay on the host.
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|error| CellAbort::Internal(error.to_string()))?
.block_on(run_cell(
source,
tools,
cancellation,
limits,
handle,
context,
module,
))
}))
.unwrap_or_else(|_| Err(CellAbort::Internal("cell worker panicked".into())));
let _ = reply.send(result);
})
.map_err(|error| CellAbort::Internal(format!("cannot start cell worker: {error}")))?;
result
.await
.map_err(|_| CellAbort::Internal("cell worker ended without a reply".into()))?
}
}
async fn run_cell(
source: String,
executor: Arc<dyn ToolExecutor>,
cancellation: CancellationToken,
limits: CellLimits,
host: Handle,
context: CellContext,
module: bool,
) -> Result<CellResult, CellAbort> {
if cancellation.is_cancelled() {
return Err(CellAbort::Cancelled);
}
let names = executor.names();
let scope = ToolScope::new(
context.clone(),
executor,
cancellation.child_token(),
host.clone(),
);
initialize_platform();
let mut runtime = JsRuntime::try_new(RuntimeOptions {
extensions: vec![maka_code::init()],
create_params: Some(v8::CreateParams::default().heap_limits(0, limits.heap_bytes)),
..Default::default()
})
.map_err(|error| CellAbort::Internal(error.to_string()))?;
runtime.op_state().borrow_mut().put(scope.clone());
runtime.op_state().borrow_mut().put(context.clone());
let stopped = Arc::new(Mutex::new(None));
let isolate = runtime.v8_isolate().thread_safe_handle();
let heap_stop = stopped.clone();
let heap_isolate = isolate.clone();
runtime.add_near_heap_limit_callback(move |current, _initial| {
heap_stop
.lock()
.unwrap()
.get_or_insert(StopReason::HeapLimit);
heap_isolate.terminate_execution();
// Let V8 unwind termination instead of immediately taking its fatal
// OOM path. This is a best-effort heap guard, not process containment.
current.saturating_add(16 * 1024 * 1024)
});
let finished = CancellationToken::new();
let _finish_on_drop = finished.clone().drop_guard();
let budget = ExecutionBudget::new(limits.timeout);
let monitor_budget = budget.clone();
let monitor_finished = finished.clone();
let monitor_cancel = cancellation.clone();
let monitor_stopped = stopped.clone();
let monitor = host.spawn(async move {
let reason = tokio::select! {
biased;
_ = monitor_finished.cancelled() => return,
_ = monitor_cancel.cancelled() => StopReason::Cancelled,
_ = monitor_budget.exhausted() => StopReason::ExecutionBudget,
};
monitor_stopped.lock().unwrap().get_or_insert(reason);
monitor_cancel.cancel();
isolate.terminate_execution();
});
// V8 termination interrupts CPU work; the token also wakes idle JS timers.
let evaluation = tokio::select! {
biased;
_ = cancellation.cancelled() => {
stopped.lock().unwrap().get_or_insert(StopReason::Cancelled);
Ok(Err(CellDiagnostic::new(CellDiagnosticKind::ExecutionError, "cell cancelled")))
}
result = budget
.run(evaluate(
&mut runtime,
&source,
&names,
context.metadata(),
module,
))
=> result,
};
let result = match evaluation {
Ok(result) => result,
Err(()) => {
stopped
.lock()
.unwrap()
.get_or_insert(StopReason::ExecutionBudget);
Err(CellDiagnostic::limit("cell execution budget exceeded"))
}
};
if module || result.is_err() {
scope.cancel();
}
// No more ops can enter once the runtime is destroyed.
drop(runtime);
scope.settle().await;
finished.cancel();
monitor
.await
.map_err(|error| CellAbort::Internal(error.to_string()))?;
// A caught JS exception or a concurrent cancellation cannot turn an
// uncertain effect/commit into an ordinary successful tool result.
if let Some(error) = context.failure() {
return Err(CellAbort::Tool(error));
}
let result = match *stopped.lock().unwrap() {
Some(StopReason::Cancelled) => return Err(CellAbort::Cancelled),
Some(StopReason::ExecutionBudget) => {
Err(CellDiagnostic::limit("cell execution budget exceeded"))
}
Some(StopReason::HeapLimit) => Err(CellDiagnostic::limit("JavaScript heap")),
None => result,
};
Ok(complete(result, scope.calls()))
}
#[derive(Clone, Copy)]
enum StopReason {
Cancelled,
ExecutionBudget,
HeapLimit,
}
fn initialize_platform() {
static INIT: Once = Once::new();
INIT.call_once(|| JsRuntime::init_platform(None));
}
/// Codex-style JavaScript property spelling. Runtime construction rejects collisions.
pub fn tool_identifier(name: &str) -> String {
let name: String = name
.chars()
.enumerate()
.map(|(index, c)| {
if c == '_' || c == '$' || c.is_ascii_alphabetic() || (index > 0 && c.is_ascii_digit())
{
c
} else {
'_'
}
})
.collect();
if name.is_empty() { "_".into() } else { name }
}