blob: 41f23b2a7222e51a46722bc7b8f1929ca7f60104 [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 std::sync::Arc;
use std::time::Duration;
use maka_agent_facts::tools::ToolExecutor;
use maka_agent_facts::tools::ToolFuture;
use maka_code_runtime::{CellDiagnostic, CellDiagnosticKind, CellLimits, CellResult};
use maka_js_runtime::CodeExecutor;
use serde_json::{Value, json};
use tokio::sync::{Barrier, Notify};
use tokio_util::sync::CancellationToken;
struct Tools<F>(F);
impl<F> ToolExecutor for Tools<F>
where
F: Fn(String, Value, CancellationToken) -> ToolFuture + Send + Sync + 'static,
{
fn names(&self) -> Vec<String> {
vec!["echo".into(), "wait".into()]
}
fn invoke(&self, name: String, input: Value, cancel: CancellationToken) -> ToolFuture {
(self.0)(name, input, cancel)
}
}
fn echo() -> Arc<dyn ToolExecutor> {
Arc::new(Tools(|_name, value, _cancel| -> ToolFuture {
Box::pin(async { Ok(value) })
}))
}
fn executor() -> CodeExecutor {
CodeExecutor::new(CellLimits {
timeout: Duration::from_secs(5),
..Default::default()
})
.unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fresh_cells_await_parallel_rust_tools_without_leaking_globals() {
let barrier = Arc::new(Barrier::new(2));
let tools = Arc::new(Tools(move |_name, value, _cancel| -> ToolFuture {
let barrier = barrier.clone();
Box::pin(async move {
barrier.wait().await;
Ok(value)
})
}));
let engine = executor();
let output = engine.execute(
"globalThis.secret = 42; return await Promise.all([tools.echo({id:1}), tools.echo({id:2})]);".into(),
tools, CancellationToken::new(),
).await.unwrap();
assert_eq!(output.success_value(), json!([{"id":1},{"id":2}]));
assert_eq!(
output
.calls()
.iter()
.map(|call| call.index)
.collect::<Vec<_>>(),
[1, 2]
);
let output = engine.execute(
"return [globalThis.secret ?? null, typeof process, typeof Deno, typeof fetch, Object.keys(tools)];".into(),
echo(), CancellationToken::new(),
).await.unwrap();
assert_eq!(
output.success_value(),
json!([
null,
"undefined",
"undefined",
"undefined",
["echo", "wait"]
])
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn host_waits_do_not_spend_execution_budget_even_for_unawaited_effects() {
for source in [
"tools.wait({}); return 9;",
"await tools.wait({}); return 9;",
] {
let started = Arc::new(Notify::new());
let released = Arc::new(Notify::new());
let tools = {
let started = started.clone();
let released = released.clone();
Arc::new(Tools(move |_name, _value, _cancel| -> ToolFuture {
let started = started.clone();
let released = released.clone();
Box::pin(async move {
started.notify_one();
released.notified().await;
Ok(json!("settled"))
})
}))
};
let mut task = tokio::spawn(async move {
CodeExecutor::new(CellLimits {
timeout: Duration::from_millis(200),
..Default::default()
})
.unwrap()
.execute(source.into(), tools, CancellationToken::new())
.await
});
tokio::time::timeout(Duration::from_secs(5), started.notified())
.await
.unwrap();
assert!(
tokio::time::timeout(Duration::from_millis(500), &mut task)
.await
.is_err()
);
released.notify_one();
assert_eq!(task.await.unwrap().unwrap().success_value(), json!(9));
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn javascript_failure_cancels_and_drains_dispatched_tools() {
// Both exceptions and successful module completion cancel unawaited work,
// but neither may release the cell before the admitted effect drains.
for fail in [false, true] {
let dispatched = Arc::new(Notify::new());
let cancelled = Arc::new(Notify::new());
let released = Arc::new(Notify::new());
let tools = {
let dispatched = dispatched.clone();
let cancelled = cancelled.clone();
let released = released.clone();
Arc::new(Tools(
move |name, _value, cancel: CancellationToken| -> ToolFuture {
let dispatched = dispatched.clone();
let cancelled = cancelled.clone();
let released = released.clone();
Box::pin(async move {
if name == "echo" {
dispatched.notified().await;
return Ok(Value::Null);
}
dispatched.notify_one();
cancel.cancelled().await;
cancelled.notify_one();
released.notified().await;
Ok(Value::Null)
})
},
))
};
// Admission precedes dispatch. Wait for the tool to enter before failing JS,
// otherwise cancellation may correctly prevent dispatch altogether.
let mut task = tokio::spawn(async move {
let context = maka_code_runtime::CellContext::new(
maka_code_runtime::CellStore::default(),
vec![],
);
executor()
.execute_module(
format!(
"tools.wait({{}}); await tools.echo({{}}); {}",
if fail {
"throw new Error('cell-failure');"
} else {
"text('finished');"
}
),
tools,
CancellationToken::new(),
context,
)
.await
});
tokio::time::timeout(Duration::from_secs(5), cancelled.notified())
.await
.unwrap();
assert!(
tokio::time::timeout(Duration::from_millis(50), &mut task)
.await
.is_err()
);
released.notify_one();
let result = task.await.unwrap().unwrap();
if fail {
assert!(
matches!(result, CellResult::Failure { error: CellDiagnostic { kind: CellDiagnosticKind::ExecutionError, message }, tool_calls } if message.contains("cell-failure") && tool_calls.len() == 2)
);
} else {
assert!(
matches!(result, CellResult::Success { tool_calls, .. } if tool_calls.len() == 2)
);
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dropping_caller_cancels_tools_without_blocking_independent_cells() {
let engine = CodeExecutor::new(CellLimits::default()).unwrap();
let started = Arc::new(Notify::new());
let cancelled = Arc::new(Notify::new());
let released = Arc::new(Notify::new());
let settled = Arc::new(Notify::new());
let tools = {
let started = started.clone();
let cancelled = cancelled.clone();
let released = released.clone();
let settled = settled.clone();
Arc::new(Tools(
move |_name, _value, cancel: CancellationToken| -> ToolFuture {
let started = started.clone();
let cancelled = cancelled.clone();
let released = released.clone();
let settled = settled.clone();
Box::pin(async move {
started.notify_one();
cancel.cancelled().await;
cancelled.notify_one();
released.notified().await;
settled.notify_one();
Ok(Value::Null)
})
},
))
};
let task = {
let engine = engine.clone();
tokio::spawn(async move {
engine
.execute(
"return await tools.wait({});".into(),
tools,
CancellationToken::new(),
)
.await
})
};
tokio::time::timeout(Duration::from_secs(5), started.notified())
.await
.unwrap();
task.abort();
tokio::time::timeout(Duration::from_secs(5), cancelled.notified())
.await
.unwrap();
let next = tokio::spawn(async move {
engine
.execute("return 2;".into(), echo(), CancellationToken::new())
.await
});
assert_eq!(
tokio::time::timeout(Duration::from_secs(5), next)
.await
.unwrap()
.unwrap()
.unwrap()
.success_value(),
json!(2)
);
released.notify_one();
tokio::time::timeout(Duration::from_secs(5), settled.notified())
.await
.expect("cancelled tool remains owned through cleanup");
}
trait SuccessResult {
fn success_value(&self) -> Value;
fn calls(&self) -> &[maka_code_runtime::ToolCall];
}
impl SuccessResult for CellResult {
fn success_value(&self) -> Value {
match self {
CellResult::Success { value, .. } => value.clone(),
_ => panic!("{self:?}"),
}
}
fn calls(&self) -> &[maka_code_runtime::ToolCall] {
match self {
CellResult::Success { tool_calls, .. } => tool_calls,
_ => panic!("{self:?}"),
}
}
}
#[test]
fn parent_cell_can_await_child_cell_with_one_host_blocking_worker() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.max_blocking_threads(1)
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
let engine = executor();
let child_engine = engine.clone();
let tools = Arc::new(Tools(move |_name, _input, cancellation| -> ToolFuture {
let child_engine = child_engine.clone();
Box::pin(async move {
let result = child_engine
.execute("return 42;".into(), echo(), cancellation)
.await
.map_err(|error| {
maka_agent_facts::tools::ToolError::Failed(error.to_string())
})?;
Ok(result.success_value())
})
}));
let cancellation = CancellationToken::new();
let result = tokio::time::timeout(
Duration::from_secs(5),
engine.execute(
"return await tools.wait(null);".into(),
tools,
cancellation.clone(),
),
)
.await;
cancellation.cancel();
assert_eq!(
result
.expect("parent must not occupy the worker needed by its child")
.unwrap()
.success_value(),
json!(42)
);
});
runtime.shutdown_timeout(Duration::from_secs(5));
}