blob: d4e357ddc6b8fbf9ce16158cdbf23024cc79e2c0 [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.
*/
use maka_agent_facts::tools::ToolError;
use maka_agent_facts::tools::ToolExecutor;
use maka_agent_facts::tools::ToolFuture;
use maka_code_runtime::{CellContext, CellLimits, CellOutput, CellResult, CellStore, ToolMetadata};
use maka_js_runtime::CodeExecutor;
use serde_json::{Value, json};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use tokio::sync::Notify;
use tokio_util::sync::CancellationToken;
#[derive(Default)]
struct Echo {
count: AtomicUsize,
active: Arc<AtomicUsize>,
peak: AtomicUsize,
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn model_modules_match_globals_timers_explicit_output_and_import_boundaries() {
let engine = CodeExecutor::new(CellLimits::default()).unwrap();
let tools = Arc::new(Echo::default());
let context = CellContext::new(CellStore::default(), vec![]);
let result = engine
.execute_module(
r#"
export const moduleValue = 42;
text([typeof console, typeof Deno, typeof __bootstrap, typeof Atomics,
typeof SharedArrayBuffer, typeof WebAssembly, typeof process, typeof fetch,
typeof tools.missing, this === undefined]);
for (let i = 0; i < 1000; i++) clearTimeout(setTimeout(() => text('cancelled'), 60000));
await new Promise(resolve => setTimeout(resolve, 0));
text(await tools.echo({value:moduleValue}));
setTimeout(() => text('late'), 60000);
exit();
text('unreachable');
"#
.into(),
tools.clone(),
CancellationToken::new(),
context.clone(),
)
.await
.unwrap();
assert!(matches!(result, CellResult::Success { .. }), "{result:?}");
let output = serde_json::to_value(context.take_output()).unwrap();
assert_eq!(
output,
json!([
{"kind":"text","text":"[\"undefined\",\"undefined\",\"undefined\",\"undefined\",\"undefined\",\"undefined\",\"undefined\",\"undefined\",\"undefined\",true]"},
{"kind":"text","text":"{\"value\":42}"}
])
);
for source in [
"return 42;",
"import x from 'node:fs';",
"await import('node:fs');",
] {
let context = CellContext::new(CellStore::default(), vec![]);
let result = engine
.execute_module(
source.into(),
tools.clone(),
CancellationToken::new(),
context,
)
.await
.unwrap();
assert!(
matches!(result, CellResult::Failure { .. }),
"{source}: {result:?}"
);
}
}
impl ToolExecutor for Echo {
fn names(&self) -> Vec<String> {
vec!["echo".into()]
}
fn invoke(&self, _: String, value: Value, _: CancellationToken) -> ToolFuture {
self.count.fetch_add(1, Ordering::SeqCst);
let active = self.active.clone();
self.peak
.fetch_max(active.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst);
Box::pin(async move {
tokio::task::yield_now().await;
active.fetch_sub(1, Ordering::SeqCst);
Ok(value)
})
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn output_survives_errors_and_json_store_does_not_retain_heap() {
let engine = CodeExecutor::new(CellLimits::default()).unwrap();
let store = CellStore::default();
let context = CellContext::new(
store.clone(),
vec![ToolMetadata {
name: "echo".into(),
description: "fixture".into(),
}],
);
let tools = Arc::new(Echo::default());
let result = engine.execute_with_context(
"globalThis.secret = 9; store('data', {n:42}); text(ALL_TOOLS[0].name); await new Promise(r => setTimeout(r, 1)); throw new Error('after output');".into(),
tools.clone(), CancellationToken::new(), context.clone()).await.unwrap();
assert!(matches!(result, CellResult::Failure { .. }));
assert!(matches!(&context.take_output()[0], CellOutput::Text { text } if text == "echo"));
context.commit().unwrap();
let next = CellContext::new(store.clone(), vec![]);
let result = engine
.execute_with_context(
"return [load('data'), typeof secret, load('missing') === undefined];".into(),
tools.clone(),
CancellationToken::new(),
next,
)
.await
.unwrap();
assert_eq!(
serde_json::to_value(result).unwrap()["value"],
json!([{"n":42},"undefined",true])
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn independent_tool_fanout_has_no_concurrency_gate() {
struct GatedEcho {
echo: Echo,
gate: Arc<tokio::sync::Barrier>,
}
impl ToolExecutor for GatedEcho {
fn names(&self) -> Vec<String> {
self.echo.names()
}
fn invoke(
&self,
name: String,
value: Value,
cancellation: CancellationToken,
) -> ToolFuture {
let call = self.echo.invoke(name, value, cancellation);
let gate = self.gate.clone();
Box::pin(async move {
gate.wait().await;
call.await
})
}
}
let engine = CodeExecutor::new(CellLimits::default()).unwrap();
let tools = Arc::new(GatedEcho {
echo: Echo::default(),
gate: Arc::new(tokio::sync::Barrier::new(16)),
});
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
engine.execute(
"return await Promise.all(Array.from({length:16}, (_,i) => tools.echo(i)));".into(),
tools.clone(),
CancellationToken::new(),
),
)
.await
.expect("all independent tools start before any completes")
.unwrap();
assert_eq!(
serde_json::to_value(result).unwrap()["value"],
json!((0..16).collect::<Vec<_>>())
);
assert_eq!(tools.echo.count.load(Ordering::SeqCst), 16);
assert_eq!(tools.echo.peak.load(Ordering::SeqCst), 16);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fatal_failure_is_observable_before_other_accepted_work_finishes_cleanup() {
struct Faults {
entered: Arc<Notify>,
release: Arc<Notify>,
later: AtomicUsize,
}
impl ToolExecutor for Faults {
fn names(&self) -> Vec<String> {
["slow", "fail", "later"].map(String::from).into()
}
fn invoke(&self, name: String, _: Value, _: CancellationToken) -> ToolFuture {
let entered = self.entered.clone();
let release = self.release.clone();
if name == "later" {
self.later.fetch_add(1, Ordering::SeqCst);
}
Box::pin(async move {
match name.as_str() {
"slow" => {
entered.notify_one();
release.notified().await;
Ok(Value::Null)
}
"fail" => {
entered.notified().await;
Err(ToolError::Persistence("injected".into()))
}
_ => Ok(Value::Null),
}
})
}
}
tokio::time::timeout(std::time::Duration::from_secs(5), async {
let tools = Arc::new(Faults { entered: Arc::new(Notify::new()), release: Arc::new(Notify::new()), later: AtomicUsize::new(0) });
let engine = CodeExecutor::new(CellLimits::default()).unwrap();
let context = CellContext::new(CellStore::default(), vec![]);
let worker_context = context.clone();
let worker_tools = tools.clone();
let mut worker = tokio::spawn(async move {
engine.execute_with_context("const slow = tools.slow({}); try { await tools.fail({}); } catch {} try { await tools.later({}); } catch {} await slow;".into(),
worker_tools, CancellationToken::new(), worker_context).await
});
context.yielded().await;
assert!(matches!(context.failure(), Some(ToolError::Persistence(_))));
assert!(futures_util::poll!(&mut worker).is_pending(), "admitted work remains owned during cleanup");
tools.release.notify_one();
assert!(matches!(worker.await.unwrap(), Err(maka_code_runtime::CellAbort::Tool(ToolError::Persistence(_)))));
assert_eq!(tools.later.load(Ordering::SeqCst), 0);
}).await.expect("fatal notification must not wait for cleanup");
}