blob: 9cdbb57f14349204102d4efeafcb33cff6866ccd [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, ToolExecutor, ToolFuture};
use maka_code_runtime::{
CellAbort, CellContext, CellDiagnosticKind, CellLimits, CellOutput, CellResult, CellStore,
ToolMetadata,
};
use maka_python_runtime::CodeExecutor;
use serde_json::{Value, json};
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
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, cancellation: CancellationToken) -> ToolFuture {
(self.0)(name, input, cancellation)
}
}
fn echo() -> Arc<dyn ToolExecutor> {
Arc::new(Tools(|_, input, _| -> ToolFuture {
Box::pin(async move { Ok(input) })
}))
}
fn executor(limits: CellLimits) -> CodeExecutor {
CodeExecutor::with_worker(limits, env!("CARGO_BIN_EXE_maka-python-worker").into()).unwrap()
}
fn context() -> CellContext {
CellContext::new(
CellStore::default(),
vec![ToolMetadata {
name: "echo".into(),
description: "echo JSON".into(),
}],
)
}
fn success(result: CellResult) -> usize {
match result {
CellResult::Success { value, tool_calls } => {
assert_eq!(value, Value::Null);
tool_calls.len()
}
other => panic!("{other:?}"),
}
}
async fn run(source: &str, context: CellContext) -> Result<CellResult, CellAbort> {
executor(CellLimits::default())
.execute_module(source.into(), echo(), CancellationToken::new(), context)
.await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn python_fanout_and_partial_failure_use_real_host_tools() {
let barrier = Arc::new(Barrier::new(2));
let tools = Arc::new(Tools(move |_, input: Value, _| -> ToolFuture {
let barrier = barrier.clone();
Box::pin(async move {
barrier.wait().await;
if input["id"] == 2 {
Err(ToolError::Failed("failed-two".into()))
} else {
Ok(input)
}
})
}));
let context = context();
let result = tokio::time::timeout(
Duration::from_secs(10),
executor(CellLimits::default()).execute_module(
r#"
import asyncio
async def settled(n):
try:
return {"ok": True, "value": await tools.echo(id=n)}
except Exception as exc:
return {"ok": False, "error": str(exc)}
text(await asyncio.gather(settled(1), settled(2)))
"#
.into(),
tools,
CancellationToken::new(),
context.clone(),
),
)
.await
.unwrap()
.unwrap();
assert_eq!(success(result), 2);
let outputs = context.take_output();
let CellOutput::Text { text } = &outputs[0] else {
panic!("{outputs:?}")
};
let output: Value = serde_json::from_str(text).unwrap();
assert_eq!(output[0], json!({"ok":true,"value":{"id":1}}));
assert_eq!(output[1]["ok"], false);
assert!(output[1]["error"].as_str().unwrap().contains("failed-two"));
}
#[tokio::test]
async fn output_store_metadata_and_fresh_globals_obey_cell_contract() {
let store = CellStore::default();
let first = CellContext::new(
store.clone(),
vec![ToolMetadata {
name: "echo".into(),
description: "echo JSON".into(),
}],
);
success(run(r#"
secret = "private"
print(secret)
store("receipt", {"text": "你好", "values": [1, True, None]})
text(ALL_TOOLS[0].name)
image({"type":"image", "data":"R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs=", "mimeType":"image/gif"}, detail="original")
notify("ready")
await yield_control()
secret
"#,first.clone()).await.unwrap());
let outputs = first.take_output();
assert_eq!(outputs.len(), 2);
assert!(matches!(&outputs[0],CellOutput::Text{text} if text=="echo"));
assert!(matches!(
&outputs[1],
CellOutput::Media {
detail: Some(maka_agent_facts::tool_output::ImageDetail::Original),
..
}
));
assert_eq!(
first.take_notifications().into_iter().collect::<Vec<_>>(),
["ready"]
);
first.commit().unwrap();
let second = CellContext::new(store, vec![]);
success(
run(
"text(load('receipt'))\ntry:\n text(secret)\nexcept NameError:\n text('fresh')",
second.clone(),
)
.await
.unwrap(),
);
let outputs = second.take_output();
assert!(matches!(&outputs[1],CellOutput::Text{text} if text=="fresh"));
let CellOutput::Text { text } = &outputs[0] else {
panic!()
};
assert_eq!(
serde_json::from_str::<Value>(text).unwrap(),
json!({"text":"你好","values":[1,true,null]})
);
}
#[tokio::test]
async fn json_boundary_rejects_lossy_types_and_cycles_before_effects() {
for value in ["{1: 'bad'}", "float('nan')", "b'bytes'", "2**100", "{1,2}"] {
let context = context();
success(
run(
&format!(
"try:\n await tools.echo({value})\nexcept Exception:\n text('rejected')"
),
context.clone(),
)
.await
.unwrap(),
);
assert!(matches!(&context.take_output()[0],CellOutput::Text{text} if text=="rejected"));
}
let result = run("a=[]\na.append(a)\nawait tools.echo(a)", context())
.await
.unwrap();
assert!(matches!(result,CellResult::Failure{tool_calls,..} if tool_calls.is_empty()));
let context = context();
assert_eq!(
success(
run(
"text(await tools.echo({'n':18446744073709551615}))",
context.clone()
)
.await
.unwrap()
),
1
);
assert!(
matches!(&context.take_output()[0],CellOutput::Text{text} if text.contains("18446744073709551615"))
);
}
#[tokio::test]
async fn cpu_memory_parse_and_os_errors_stay_inside_worker() {
for (source, limits, kind) in [
(
"while True: pass",
CellLimits {
timeout: Duration::from_millis(150),
..Default::default()
},
CellDiagnosticKind::LimitExceeded,
),
(
"items=['x'*32000 for i in range(10000)]",
CellLimits {
heap_bytes: 8 * 1024 * 1024,
..Default::default()
},
CellDiagnosticKind::LimitExceeded,
),
("def", CellLimits::default(), CellDiagnosticKind::ParseError),
(
"open('/etc/passwd').read()",
CellLimits::default(),
CellDiagnosticKind::ExecutionError,
),
] {
let result = executor(limits)
.execute_module(source.into(), echo(), CancellationToken::new(), context())
.await
.unwrap();
assert!(
matches!(result,CellResult::Failure{error,..} if error.kind==kind),
"{source}"
);
}
success(run("text('Host still works')", context()).await.unwrap());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_waits_and_python_sleep_do_not_spend_execution_budget() {
let tools = Arc::new(Tools(|_, input, _| -> ToolFuture {
Box::pin(async move {
tokio::time::sleep(Duration::from_millis(400)).await;
Ok(input)
})
}));
let result = executor(CellLimits {
timeout: Duration::from_millis(200),
..Default::default()
})
.execute_module(
"import asyncio\nawait tools.wait({})\nawait asyncio.sleep(0.4)\ntext('done')".into(),
tools,
CancellationToken::new(),
context(),
)
.await
.unwrap();
assert_eq!(success(result), 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn completion_and_failure_cancel_unawaited_tools_and_wait_for_cleanup() {
for tail in [
"text('done')",
"raise ValueError('bad')",
"exit()\nawait tools.echo({})",
] {
let entered = Arc::new(Notify::new());
let cancelled = Arc::new(Notify::new());
let release = Arc::new(Notify::new());
let tools = {
let entered = entered.clone();
let cancelled = cancelled.clone();
let release = release.clone();
Arc::new(Tools(
move |name: String, _, cancel: CancellationToken| -> ToolFuture {
let entered = entered.clone();
let cancelled = cancelled.clone();
let release = release.clone();
Box::pin(async move {
if name == "echo" {
entered.notified().await;
return Ok(Value::Null);
}
entered.notify_one();
cancel.cancelled().await;
cancelled.notify_one();
release.notified().await;
Ok(Value::Null)
})
},
))
};
let source = format!("tools.wait({{}})\nawait tools.echo({{}})\n{tail}");
let mut task = tokio::spawn(async move {
executor(CellLimits::default())
.execute_module(source, tools, CancellationToken::new(), context())
.await
});
tokio::time::timeout(Duration::from_secs(5), cancelled.notified())
.await
.unwrap();
assert!(
tokio::time::timeout(Duration::from_millis(30), &mut task)
.await
.is_err()
);
release.notify_one();
let result = task.await.unwrap().unwrap();
match result {
CellResult::Success { tool_calls, .. } | CellResult::Failure { tool_calls, .. } => {
assert_eq!(tool_calls.len(), 2)
}
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancellation_and_dropped_observer_drain_the_owned_effect() {
for drop_observer in [false, true] {
let started = Arc::new(Notify::new());
let cleaned = Arc::new(AtomicBool::new(false));
let tools = {
let started = started.clone();
let cleaned = cleaned.clone();
Arc::new(Tools(
move |_, _, cancel: CancellationToken| -> ToolFuture {
let started = started.clone();
let cleaned = cleaned.clone();
Box::pin(async move {
started.notify_one();
cancel.cancelled().await;
tokio::time::sleep(Duration::from_millis(30)).await;
cleaned.store(true, Ordering::SeqCst);
Ok(Value::Null)
})
},
))
};
let cancellation = CancellationToken::new();
let token = cancellation.clone();
let task = tokio::spawn(async move {
executor(CellLimits::default())
.execute_module("await tools.wait({})".into(), tools, token, context())
.await
});
tokio::time::timeout(Duration::from_secs(5), started.notified())
.await
.unwrap();
if drop_observer {
task.abort();
} else {
cancellation.cancel();
assert!(matches!(task.await.unwrap(), Err(CellAbort::Cancelled)));
}
tokio::time::timeout(Duration::from_secs(5), async {
while !cleaned.load(Ordering::SeqCst) {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
}
let cancellation = CancellationToken::new();
let token = cancellation.clone();
let task = tokio::spawn(async move {
executor(CellLimits::default())
.execute_module("while True: pass".into(), echo(), token, context())
.await
});
tokio::time::sleep(Duration::from_millis(50)).await;
cancellation.cancel();
assert!(matches!(
tokio::time::timeout(Duration::from_secs(2), task)
.await
.unwrap()
.unwrap(),
Err(CellAbort::Cancelled)
));
}
#[tokio::test]
async fn caught_fatal_tool_error_cannot_publish_success() {
let tools = Arc::new(Tools(|_, _, _| -> ToolFuture {
Box::pin(async { Err(ToolError::Persistence("journal unavailable".into())) })
}));
let context = context();
let result=executor(CellLimits::default()).execute_module("try:\n await tools.echo({})\nexcept Exception:\n text('caught')\nawait tools.echo({})".into(),tools,CancellationToken::new(),context).await;
assert!(matches!(
result,
Err(CellAbort::Tool(ToolError::Persistence(_)))
));
}
#[tokio::test]
async fn json_depth_is_independent_of_request_and_reply_framing() {
for depth in [123, 124, 125, 126, 127, 128] {
for host_value in [false, true] {
let tools = Arc::new(Tools(move |_, input, _| -> ToolFuture {
Box::pin(async move {
Ok(if host_value {
(0..depth).fold(json!("leaf"), |value, _| json!([value]))
} else {
input
})
})
}));
let context = context();
let source = format!(
"v='leaf'\nfor _ in range({depth}):\n v=[v]\ntry:\n v=await tools.echo({})\n for _ in range({depth}):\n v=v[0]\n text(v)\nexcept Exception:\n text('rejected')",
if host_value { "None" } else { "v" }
);
success(
executor(CellLimits::default())
.execute_module(source, tools, CancellationToken::new(), context.clone())
.await
.unwrap(),
);
let expected = if depth < 128 { "leaf" } else { "rejected" };
assert!(
matches!(&context.take_output()[0], CellOutput::Text { text } if text == expected),
"depth {depth}, host value {host_value}"
);
}
}
}
#[tokio::test]
async fn parent_cell_can_await_child_without_a_worker_pool_deadlock() {
let child = executor(CellLimits::default());
let tools = Arc::new(Tools(move |_, _, cancel| -> ToolFuture {
let child = child.clone();
Box::pin(async move {
success(
child
.execute_module("text('child')".into(), echo(), cancel, context())
.await
.unwrap(),
);
Ok(json!("child settled"))
})
}));
let result = tokio::time::timeout(
Duration::from_secs(5),
executor(CellLimits::default()).execute_module(
"text(await tools.echo({}))".into(),
tools,
CancellationToken::new(),
context(),
),
)
.await
.unwrap()
.unwrap();
assert_eq!(success(result), 1);
}
#[tokio::test]
async fn execution_budget_accumulates_across_host_resumes() {
let result = tokio::time::timeout(
Duration::from_secs(5),
executor(CellLimits {
timeout: Duration::from_millis(150),
..Default::default()
})
.execute_module(
"while True:\n await tools.echo({})".into(),
echo(),
CancellationToken::new(),
context(),
),
)
.await
.unwrap()
.unwrap();
assert!(
matches!(result,CellResult::Failure{error,tool_calls} if error.kind==CellDiagnosticKind::LimitExceeded && tool_calls.len()>1)
);
}
#[tokio::test]
async fn loading_deep_host_or_javascript_store_values_is_catchable() {
let context = context();
context
.store(
"deep".into(),
(0..132).fold(json!(0), |value, _| json!([value])),
)
.unwrap();
success(
run(
"try:\n load('deep')\nexcept Exception:\n text('rejected')",
context.clone(),
)
.await
.unwrap(),
);
assert!(matches!(&context.take_output()[0],CellOutput::Text{text} if text=="rejected"));
}