| /* | |
| * 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")); | |
| } |