use adk_core::{Result, Tool, ToolContext};
use async_trait::async_trait;
use serde_json::{Value, json};
use std::sync::Arc;
const DEFAULT_TIMEOUT_SECS: u64 = 30;
const MIN_TIMEOUT_SECS: u64 = 1;
const MAX_TIMEOUT_SECS: u64 = 300;
const BASE_DESCRIPTION: &str = "Execute Python code in a sandboxed interpreter.";
pub struct MontyPythonCodeTool {
repl: bool,
description: String,
#[cfg(feature = "code-embedded-python")]
inner: enabled::Inner,
}
impl MontyPythonCodeTool {
pub fn new() -> Self {
Self::sandboxed(false)
}
pub fn repl() -> Self {
Self::sandboxed(true)
}
#[cfg(feature = "code-embedded-python")]
fn sandboxed(repl: bool) -> Self {
let builder = MontyPythonCodeToolBuilder::new();
let built = if repl { builder.build_repl() } else { builder.build_one_shot() };
built.expect("an empty host-function registry always validates")
}
#[cfg(not(feature = "code-embedded-python"))]
fn sandboxed(repl: bool) -> Self {
Self { repl, description: BASE_DESCRIPTION.to_string() }
}
#[cfg(feature = "code-embedded-python")]
pub fn builder() -> MontyPythonCodeToolBuilder {
MontyPythonCodeToolBuilder::new()
}
}
impl Default for MontyPythonCodeTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for MontyPythonCodeTool {
fn name(&self) -> &str {
"monty_python_code"
}
fn description(&self) -> &str {
&self.description
}
fn required_scopes(&self) -> &[&str] {
&["code:execute"]
}
fn parameters_schema(&self) -> Option<Value> {
let mut properties = json!({
"code": {
"type": "string",
"description": "Python source to execute."
},
"input": {
"description": "Optional JSON value bound to the `input` variable."
},
"timeout_secs": {
"type": "integer",
"default": DEFAULT_TIMEOUT_SECS,
"minimum": MIN_TIMEOUT_SECS,
"maximum": MAX_TIMEOUT_SECS,
"description": "Time budget in seconds (default 30; values outside 1-300 \
are clamped)."
}
});
if self.repl {
properties["reset"] = json!({
"type": "boolean",
"default": false,
"description": "REPL mode only: discard the persistent session before executing."
});
}
Some(json!({
"type": "object",
"properties": properties,
"required": ["code"]
}))
}
fn response_schema(&self) -> Option<Value> {
Some(json!({
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["success", "failed", "timeout", "rejected"],
"description": "'success': the script completed. 'failed': a Python \
exception propagated (traceback in stderr). 'timeout': \
the time budget was exceeded. 'rejected': bad arguments \
or the feature is disabled."
},
"stdout": {
"type": "string",
"description": "Captured print() output."
},
"stderr": {
"type": "string",
"description": "Python traceback or rejection reason; empty on success."
},
"output": {
"description": "JSON value of the script's final expression; null unless \
status is 'success'."
},
"stdoutTruncated": {
"type": "boolean",
"description": "True when stdout was cut at the policy limit."
},
"stderrTruncated": {
"type": "boolean",
"description": "True when stderr was cut at the policy limit."
},
"durationMs": {
"type": "integer",
"description": "Wall-clock execution time in milliseconds."
}
},
"required": ["status", "stdout", "stderr", "output", "stdoutTruncated",
"stderrTruncated", "durationMs"]
}))
}
fn is_read_only(&self) -> bool {
false
}
fn is_concurrency_safe(&self) -> bool {
!self.repl
}
async fn execute(&self, ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
let Some(code) = args.get("code").and_then(Value::as_str) else {
return Ok(rejected("missing required field: code"));
};
let input = args.get("input").cloned();
let timeout_secs = args
.get("timeout_secs")
.and_then(Value::as_u64)
.unwrap_or(DEFAULT_TIMEOUT_SECS)
.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS);
let reset = args.get("reset").and_then(Value::as_bool).unwrap_or(false);
self.execute_python(ctx, code, input, timeout_secs, reset).await
}
}
fn rejected(message: &str) -> Value {
json!({
"status": "rejected",
"stdout": "",
"stderr": message,
"output": null,
"stdoutTruncated": false,
"stderrTruncated": false,
"durationMs": 0,
})
}
#[cfg(feature = "code-embedded-python")]
pub use enabled::MontyPythonCodeToolBuilder;
#[cfg(feature = "code-embedded-python")]
mod enabled {
use super::*;
use adk_code::{
CodeExecutor, ExecutionError, ExecutionLanguage, ExecutionPayload, ExecutionRequest,
ExecutionResult, HostFunction, HostFunctionError, MontyBuildError, MontyExecutorBuilder,
MontyOneShotExecutor, MontyReplExecutor, PathAccess,
};
use serde_json::Map;
use std::collections::HashMap;
use std::future::Future;
use std::path::PathBuf;
use std::time::Duration;
use tokio::sync::RwLock;
use tracing::debug;
const DEFAULT_MAX_SESSIONS: usize = 100;
#[derive(Clone, PartialEq, Eq, Hash)]
pub(super) struct SessionKey {
app: String,
user: String,
session: String,
}
impl SessionKey {
pub(super) fn new(ctx: &dyn ToolContext) -> Self {
Self {
app: ctx.app_name().to_string(),
user: ctx.user_id().to_string(),
session: ctx.session_id().to_string(),
}
}
}
pub(super) struct Inner {
builder: MontyExecutorBuilder,
granted: adk_code::SandboxPolicy,
oneshot: Option<Arc<MontyOneShotExecutor>>,
sessions: RwLock<LruSessions>,
}
pub(super) struct LruSessions {
capacity: usize,
clock: u64,
entries: HashMap<SessionKey, (Arc<MontyReplExecutor>, u64)>,
}
impl LruSessions {
fn new(capacity: usize) -> Self {
Self { capacity: capacity.max(1), clock: 0, entries: HashMap::new() }
}
fn get(&mut self, key: &SessionKey) -> Option<Arc<MontyReplExecutor>> {
self.clock += 1;
let clock = self.clock;
self.entries.get_mut(key).map(|(executor, used)| {
*used = clock;
executor.clone()
})
}
fn insert(&mut self, key: SessionKey, executor: Arc<MontyReplExecutor>) {
self.clock += 1;
if !self.entries.contains_key(&key) && self.entries.len() >= self.capacity {
if let Some(evict) = self
.entries
.iter()
.min_by_key(|(_, (_, used))| *used)
.map(|(key, _)| key.clone())
{
debug!(
session.app = %evict.app,
session.user = %evict.user,
session.id = %evict.session,
"evicting lru repl session"
);
self.entries.remove(&evict);
}
}
self.entries.insert(key, (executor, self.clock));
}
fn remove(&mut self, key: &SessionKey) {
self.entries.remove(key);
}
#[cfg(test)]
fn contains(&self, key: &SessionKey) -> bool {
self.entries.contains_key(key)
}
}
pub struct MontyPythonCodeToolBuilder {
builder: MontyExecutorBuilder,
max_sessions: usize,
}
impl Default for MontyPythonCodeToolBuilder {
fn default() -> Self {
Self::new()
}
}
impl MontyPythonCodeToolBuilder {
#[must_use]
pub fn new() -> Self {
Self {
builder: MontyExecutorBuilder::new().script_name("agent_snippet"),
max_sessions: DEFAULT_MAX_SESSIONS,
}
}
#[must_use]
pub fn allow_path(
mut self,
virtual_path: impl Into<String>,
host_path: impl Into<PathBuf>,
access: PathAccess,
) -> Self {
self.builder = self.builder.allow_path(virtual_path, host_path, access);
self
}
#[must_use]
pub fn environ<K, V>(mut self, vars: impl IntoIterator<Item = (K, V)>) -> Self
where
K: Into<String>,
V: Into<String>,
{
self.builder = self.builder.environ(vars);
self
}
#[must_use]
pub fn environ_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.builder = self.builder.environ_var(key, value);
self
}
#[must_use]
pub fn system_clock(mut self) -> Self {
self.builder = self.builder.system_clock();
self
}
#[must_use]
pub fn function(mut self, function: Arc<dyn HostFunction>) -> Self {
self.builder = self.builder.function(function);
self
}
#[must_use]
pub fn function_fn<F, Fut>(
mut self,
name: impl Into<String>,
description: impl Into<String>,
func: F,
) -> Self
where
F: Fn(Vec<Value>, Map<String, Value>) -> Fut + Send + Sync + 'static,
Fut: Future<Output = std::result::Result<Value, HostFunctionError>> + Send + 'static,
{
self.builder = self.builder.function_fn(name, description, func);
self
}
#[must_use]
pub fn max_memory(mut self, bytes: usize) -> Self {
self.builder = self.builder.max_memory(bytes);
self
}
#[must_use]
pub fn host_function_timeout(mut self, timeout: Duration) -> Self {
self.builder = self.builder.host_function_timeout(timeout);
self
}
#[must_use]
pub fn max_sessions(mut self, max_sessions: usize) -> Self {
self.max_sessions = max_sessions;
self
}
pub fn build_one_shot(self) -> std::result::Result<MontyPythonCodeTool, MontyBuildError> {
let executor = Arc::new(self.builder.clone().build_one_shot()?);
Ok(MontyPythonCodeTool {
repl: false,
description: compose_description(executor.prompt_snippet()),
inner: Inner {
builder: self.builder,
granted: executor.granted_policy(),
oneshot: Some(executor),
sessions: RwLock::new(LruSessions::new(self.max_sessions)),
},
})
}
pub fn build_repl(self) -> std::result::Result<MontyPythonCodeTool, MontyBuildError> {
let probe = self.builder.clone().build_repl()?;
Ok(MontyPythonCodeTool {
repl: true,
description: compose_description(probe.prompt_snippet()),
inner: Inner {
builder: self.builder,
granted: probe.granted_policy(),
oneshot: None,
sessions: RwLock::new(LruSessions::new(self.max_sessions)),
},
})
}
}
fn compose_description(snippet: Option<String>) -> String {
match snippet {
Some(snippet) => format!("{BASE_DESCRIPTION}\n\n{snippet}"),
None => BASE_DESCRIPTION.to_string(),
}
}
impl MontyPythonCodeTool {
pub(super) async fn execute_python(
&self,
ctx: Arc<dyn ToolContext>,
code: &str,
input: Option<Value>,
timeout_secs: u64,
reset: bool,
) -> Result<Value> {
let executor: Arc<dyn CodeExecutor> = if let Some(oneshot) = &self.inner.oneshot {
oneshot.clone()
} else {
match self.repl_executor(SessionKey::new(ctx.as_ref()), reset).await {
Ok(executor) => executor,
Err(err) => return Ok(render_error(err)),
}
};
let mut sandbox = self.inner.granted.clone();
sandbox.timeout = Duration::from_secs(timeout_secs);
let request = ExecutionRequest {
language: ExecutionLanguage::Python,
payload: ExecutionPayload::Source { code: code.to_string() },
argv: vec![],
stdin: None,
input,
sandbox,
identity: None,
};
match executor.execute(request).await {
Ok(result) => Ok(render_result(result)),
Err(err) => Ok(render_error(err)),
}
}
async fn repl_executor(
&self,
key: SessionKey,
reset: bool,
) -> std::result::Result<Arc<MontyReplExecutor>, ExecutionError> {
let mut sessions = self.inner.sessions.write().await;
if reset {
sessions.remove(&key);
}
if let Some(executor) = sessions.get(&key) {
return Ok(executor);
}
let executor = Arc::new(self.inner.builder.clone().build_repl().map_err(|err| {
ExecutionError::InternalError(format!("failed to build repl session: {err}"))
})?);
debug!(
session.app = %key.app,
session.user = %key.user,
session.id = %key.session,
"created repl session"
);
sessions.insert(key, executor.clone());
Ok(executor)
}
#[cfg(test)]
pub(super) async fn has_session(&self, ctx: &dyn ToolContext) -> bool {
self.inner.sessions.read().await.contains(&SessionKey::new(ctx))
}
}
fn render_result(result: ExecutionResult) -> Value {
json!({
"status": result.status,
"stdout": result.stdout,
"stderr": result.stderr,
"output": result.output,
"stdoutTruncated": result.stdout_truncated,
"stderrTruncated": result.stderr_truncated,
"durationMs": result.duration_ms,
})
}
fn render_error(err: ExecutionError) -> Value {
let status = match err {
ExecutionError::InvalidRequest(_)
| ExecutionError::UnsupportedPolicy(_)
| ExecutionError::Rejected(_) => "rejected",
ExecutionError::Timeout(_) => "timeout",
ExecutionError::UnsupportedLanguage(_)
| ExecutionError::CompileFailed(_)
| ExecutionError::ExecutionFailed(_)
| ExecutionError::InternalError(_) => "failed",
};
json!({
"status": status,
"stdout": "",
"stderr": err.to_string(),
"output": null,
"stdoutTruncated": false,
"stderrTruncated": false,
"durationMs": 0,
})
}
}
#[cfg(not(feature = "code-embedded-python"))]
impl MontyPythonCodeTool {
pub(super) async fn execute_python(
&self,
_ctx: Arc<dyn ToolContext>,
_code: &str,
_input: Option<Value>,
_timeout_secs: u64,
_reset: bool,
) -> Result<Value> {
Ok(rejected(
"Python execution requires the 'code-embedded-python' feature. \
Enable it with: adk-tool = { features = [\"code-embedded-python\"] }",
))
}
}
#[cfg(all(test, feature = "code-embedded-python"))]
mod tests {
use super::*;
use adk_core::{CallbackContext, Content, EventActions, ReadonlyContext};
use std::sync::Mutex;
struct MockToolContext {
user: String,
session: String,
actions: Mutex<EventActions>,
content: Content,
}
impl MockToolContext {
fn new(session: &str) -> Arc<Self> {
Self::for_user("user-1", session)
}
fn for_user(user: &str, session: &str) -> Arc<Self> {
Arc::new(Self {
user: user.to_string(),
session: session.to_string(),
actions: Mutex::new(EventActions::default()),
content: Content::new("user"),
})
}
}
#[async_trait]
impl ReadonlyContext for MockToolContext {
fn invocation_id(&self) -> &str {
"inv-test"
}
fn agent_name(&self) -> &str {
"test-agent"
}
fn user_id(&self) -> &str {
&self.user
}
fn app_name(&self) -> &str {
"test-app"
}
fn session_id(&self) -> &str {
&self.session
}
fn branch(&self) -> &str {
""
}
fn user_content(&self) -> &Content {
&self.content
}
}
#[async_trait]
impl CallbackContext for MockToolContext {
fn artifacts(&self) -> Option<Arc<dyn adk_core::Artifacts>> {
None
}
}
#[async_trait]
impl ToolContext for MockToolContext {
fn function_call_id(&self) -> &str {
"call-test"
}
fn actions(&self) -> EventActions {
self.actions.lock().unwrap().clone()
}
fn set_actions(&self, actions: EventActions) {
*self.actions.lock().unwrap() = actions;
}
async fn search_memory(&self, _query: &str) -> Result<Vec<adk_core::MemoryEntry>> {
Ok(vec![])
}
}
#[test]
fn schema_includes_reset_only_in_repl_mode() {
let one_shot = MontyPythonCodeTool::new();
let schema = one_shot.parameters_schema().unwrap();
assert!(schema["properties"]["code"].is_object());
assert!(schema["properties"]["timeout_secs"].is_object());
assert!(schema["properties"].get("reset").is_none());
assert_eq!(schema["required"], json!(["code"]));
let repl = MontyPythonCodeTool::repl();
let schema = repl.parameters_schema().unwrap();
assert!(schema["properties"]["reset"].is_object());
}
#[test]
fn description_is_base_contract_plus_prompt_snippet() {
let tool = MontyPythonCodeTool::builder()
.environ_var("PROJECT", "secret-value")
.function_fn("noop", "Do nothing.", |_args, _kwargs| async move { Ok(json!(null)) })
.build_repl()
.unwrap();
let description = tool.description();
assert!(description.starts_with(BASE_DESCRIPTION));
assert!(description.contains("## Python execution environment"));
assert!(description.contains("persistent REPL session"));
assert!(description.contains("PROJECT"));
assert!(!description.contains("secret-value"));
assert!(description.contains("def noop(...):"));
}
#[tokio::test]
async fn response_schema_matches_the_actual_envelope() {
let tool = MontyPythonCodeTool::new();
let schema = tool.response_schema().expect("the envelope is fixed, so it is declared");
let mut declared: Vec<&str> =
schema["properties"].as_object().unwrap().keys().map(String::as_str).collect();
let mut required: Vec<&str> =
schema["required"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
declared.sort_unstable();
required.sort_unstable();
assert_eq!(declared, required);
for args in [json!({"code": "21 * 2"}), json!({"code": "1 / 0"}), json!({})] {
let result = tool.execute(MockToolContext::new("s1"), args).await.unwrap();
let mut actual: Vec<&str> =
result.as_object().unwrap().keys().map(String::as_str).collect();
actual.sort_unstable();
assert_eq!(actual, declared);
let status = result["status"].as_str().unwrap();
let allowed: Vec<&str> = schema["properties"]["status"]["enum"]
.as_array()
.unwrap()
.iter()
.filter_map(Value::as_str)
.collect();
assert!(allowed.contains(&status), "undeclared status: {status}");
}
}
#[test]
fn declaration_includes_parameters_and_response() {
let tool = MontyPythonCodeTool::new();
let declaration = tool.declaration();
assert_eq!(declaration["name"], "monty_python_code");
assert!(declaration["parameters"].is_object());
assert!(declaration["response"].is_object());
}
#[test]
fn concurrency_metadata_tracks_the_mode() {
assert!(MontyPythonCodeTool::new().is_concurrency_safe());
assert!(!MontyPythonCodeTool::repl().is_concurrency_safe());
assert!(!MontyPythonCodeTool::new().is_read_only());
}
#[tokio::test]
async fn missing_code_is_rejected() {
let tool = MontyPythonCodeTool::new();
let result = tool.execute(MockToolContext::new("s1"), json!({})).await.unwrap();
assert_eq!(result["status"], "rejected");
assert!(result["stderr"].as_str().unwrap().contains("code"));
}
#[tokio::test]
async fn one_shot_executes_and_reports_camel_case_envelope() {
let tool = MontyPythonCodeTool::new();
let result =
tool.execute(MockToolContext::new("s1"), json!({"code": "21 * 2"})).await.unwrap();
assert_eq!(result["status"], "success");
assert_eq!(result["output"], json!(42));
assert!(result.get("exitCode").is_none());
assert!(result.get("durationMs").is_some());
assert_eq!(result["stdoutTruncated"], json!(false));
assert_eq!(result["stderrTruncated"], json!(false));
}
#[tokio::test]
async fn python_exception_reports_failed_with_traceback() {
let tool = MontyPythonCodeTool::new();
let result =
tool.execute(MockToolContext::new("s1"), json!({"code": "1 / 0"})).await.unwrap();
assert_eq!(result["status"], "failed");
assert!(result["stderr"].as_str().unwrap().contains("ZeroDivisionError"));
}
#[tokio::test]
async fn repl_sessions_are_isolated_per_session_id() {
let tool = MontyPythonCodeTool::repl();
tool.execute(MockToolContext::new("alice"), json!({"code": "x = 1"})).await.unwrap();
let bob = tool.execute(MockToolContext::new("bob"), json!({"code": "x"})).await.unwrap();
assert_eq!(bob["status"], "failed");
assert!(bob["stderr"].as_str().unwrap().contains("NameError"));
let alice =
tool.execute(MockToolContext::new("alice"), json!({"code": "x + 1"})).await.unwrap();
assert_eq!(alice["output"], json!(2));
}
#[tokio::test]
async fn same_session_id_for_different_users_gets_isolated_interpreters() {
let tool = MontyPythonCodeTool::repl();
let alice = MockToolContext::for_user("alice", "s1");
let bob = MockToolContext::for_user("bob", "s1");
tool.execute(alice.clone(), json!({"code": "x = 1"})).await.unwrap();
let result = tool.execute(bob.clone(), json!({"code": "x"})).await.unwrap();
assert_eq!(result["status"], "failed");
assert!(result["stderr"].as_str().unwrap().contains("NameError"));
assert!(tool.has_session(alice.as_ref()).await);
assert!(tool.has_session(bob.as_ref()).await);
}
#[tokio::test]
async fn reset_true_clears_the_session_state() {
let tool = MontyPythonCodeTool::repl();
let ctx = MockToolContext::new("s1");
tool.execute(ctx.clone(), json!({"code": "x = 1"})).await.unwrap();
let result = tool.execute(ctx, json!({"code": "x", "reset": true})).await.unwrap();
assert_eq!(result["status"], "failed");
assert!(result["stderr"].as_str().unwrap().contains("NameError"));
}
#[tokio::test]
async fn lru_evicts_the_least_recently_used_session() {
let tool = MontyPythonCodeTool::builder().max_sessions(2).build_repl().unwrap();
tool.execute(MockToolContext::new("a"), json!({"code": "x = 1"})).await.unwrap();
tool.execute(MockToolContext::new("b"), json!({"code": "x = 2"})).await.unwrap();
tool.execute(MockToolContext::new("a"), json!({"code": "x"})).await.unwrap();
tool.execute(MockToolContext::new("c"), json!({"code": "x = 3"})).await.unwrap();
assert!(tool.has_session(MockToolContext::new("a").as_ref()).await);
assert!(!tool.has_session(MockToolContext::new("b").as_ref()).await);
assert!(tool.has_session(MockToolContext::new("c").as_ref()).await);
let result = tool.execute(MockToolContext::new("b"), json!({"code": "x"})).await.unwrap();
assert_eq!(result["status"], "failed");
}
#[tokio::test]
async fn granted_input_and_host_function_flow_through_the_tool() {
let tool = MontyPythonCodeTool::builder()
.environ_var("PROJECT", "acme")
.function_fn("double", "Double a number.", |args, _kwargs| async move {
let n = args.first().and_then(Value::as_i64).unwrap_or(0);
Ok(json!(n * 2))
})
.build_one_shot()
.unwrap();
let result = tool
.execute(
MockToolContext::new("s1"),
json!({
"code": "import os\n[double(input['n']), os.getenv('PROJECT')]",
"input": {"n": 21}
}),
)
.await
.unwrap();
assert_eq!(result["status"], "success");
assert_eq!(result["output"], json!([42, "acme"]));
}
}