Skip to content

Commit 07b5cb9

Browse files
achicuchaliy
andauthored
feat(python): add external function handler support (#394)
Allow host applications to register async callback functions that Python code can call via monty's external_functions mechanism. The handler receives raw MontyObject args and returns ExternalResult directly, avoiding unnecessary serialization. New public types: PythonExternalFnHandler, PythonExternalFns. Re-exports monty types (MontyObject, ExternalResult, etc.) for consumers. Adds BashBuilder::python_with_external_handler() builder method. --------- Co-authored-by: Mykhailo Chalyi <mike@chaliy.name>
1 parent 486d6c4 commit 07b5cb9

File tree

8 files changed

+371
-13
lines changed

8 files changed

+371
-13
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,7 @@ jobs:
113113
cargo run --example resource_limits
114114
cargo run --example text_processing
115115
cargo run --example git_workflow --features git
116-
# python_scripts requires monty git dep (not on crates.io)
117-
# cargo run --example python_scripts --features python
116+
cargo run --example python_external_functions --features python
118117
119118
# External API dependency — don't block CI on Anthropic outages
120119
- name: Run LLM agent example

crates/bashkit/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,7 @@ required-features = ["scripted_tool"]
116116
[[example]]
117117
name = "python_scripts"
118118
required-features = ["python"]
119+
120+
[[example]]
121+
name = "python_external_functions"
122+
required-features = ["python"]
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//! Python External Functions Example
2+
//!
3+
//! Demonstrates registering a host async callback that Python can call.
4+
//!
5+
//! Run with: cargo run --features python --example python_external_functions
6+
7+
use bashkit::{Bash, ExternalResult, MontyObject, PythonExternalFnHandler, PythonLimits, Result};
8+
use std::sync::Arc;
9+
10+
#[tokio::main]
11+
async fn main() -> Result<()> {
12+
let handler: PythonExternalFnHandler = Arc::new(|name, args, _kwargs| {
13+
Box::pin(async move {
14+
if name != "add" {
15+
return ExternalResult::Return(MontyObject::None);
16+
}
17+
18+
let a = match args.first() {
19+
Some(MontyObject::Int(v)) => *v,
20+
_ => 0,
21+
};
22+
let b = match args.get(1) {
23+
Some(MontyObject::Int(v)) => *v,
24+
_ => 0,
25+
};
26+
27+
ExternalResult::Return(MontyObject::Int(a + b))
28+
})
29+
});
30+
31+
let mut bash = Bash::builder()
32+
.python_with_external_handler(PythonLimits::default(), vec!["add".to_string()], handler)
33+
.build();
34+
35+
let result = bash.exec("python3 -c \"print(add(20, 22))\"").await?;
36+
assert_eq!(result.exit_code, 0);
37+
assert_eq!(result.stdout.trim(), "42");
38+
39+
println!("external function result: {}", result.stdout.trim());
40+
Ok(())
41+
}

crates/bashkit/src/builtins/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ pub use yes::Yes;
129129
pub use git::Git;
130130

131131
#[cfg(feature = "python")]
132-
pub use python::{Python, PythonLimits};
132+
pub use python::{Python, PythonExternalFnHandler, PythonExternalFns, PythonLimits};
133133

134134
use async_trait::async_trait;
135135
use std::collections::HashMap;

crates/bashkit/src/builtins/python.rs

Lines changed: 242 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ use monty::{
2020
MontyObject, MontyRun, OsFunction, PrintWriter, ResourceLimits, RunProgress,
2121
};
2222
use std::collections::HashMap;
23+
use std::future::Future;
2324
use std::path::{Path, PathBuf};
25+
use std::pin::Pin;
2426
use std::sync::Arc;
2527
use std::time::Duration;
2628

@@ -107,6 +109,41 @@ impl PythonLimits {
107109
}
108110
}
109111

112+
/// Async handler for external Python function calls.
113+
///
114+
/// Receives `(function_name, positional_args, keyword_args)` directly from monty.
115+
/// Return `ExternalResult::Return(value)` for success or `ExternalResult::Error(exc)` for failure.
116+
pub type PythonExternalFnHandler = Arc<
117+
dyn Fn(
118+
String,
119+
Vec<MontyObject>,
120+
Vec<(MontyObject, MontyObject)>,
121+
) -> Pin<Box<dyn Future<Output = ExternalResult> + Send>>
122+
+ Send
123+
+ Sync,
124+
>;
125+
126+
/// External function configuration for the Python builtin.
127+
///
128+
/// Groups function names and their async handler together.
129+
/// Configure via [`BashBuilder::python_with_external_handler`](crate::BashBuilder::python_with_external_handler).
130+
#[derive(Clone)]
131+
pub struct PythonExternalFns {
132+
/// Function names callable from Python (e.g., `"call_tool"`).
133+
names: Vec<String>,
134+
/// Async handler invoked when Python calls one of these functions.
135+
handler: PythonExternalFnHandler,
136+
}
137+
138+
impl std::fmt::Debug for PythonExternalFns {
139+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140+
f.debug_struct("PythonExternalFns")
141+
.field("names", &self.names)
142+
.field("handler", &"<fn>")
143+
.finish()
144+
}
145+
}
146+
110147
/// The python/python3 builtin command.
111148
///
112149
/// Executes Python code using the embedded Monty interpreter (pydantic/monty).
@@ -126,19 +163,38 @@ impl PythonLimits {
126163
pub struct Python {
127164
/// Resource limits for the Monty interpreter.
128165
pub limits: PythonLimits,
166+
/// Optional external function configuration.
167+
external_fns: Option<PythonExternalFns>,
129168
}
130169

131170
impl Python {
132171
/// Create with default limits.
133172
pub fn new() -> Self {
134173
Self {
135174
limits: PythonLimits::default(),
175+
external_fns: None,
136176
}
137177
}
138178

139179
/// Create with custom limits.
140180
pub fn with_limits(limits: PythonLimits) -> Self {
141-
Self { limits }
181+
Self {
182+
limits,
183+
external_fns: None,
184+
}
185+
}
186+
187+
/// Set external function names and handler.
188+
///
189+
/// External functions are callable from Python by name.
190+
/// When called, execution pauses and the handler is invoked with the raw monty arguments.
191+
pub fn with_external_handler(
192+
mut self,
193+
names: Vec<String>,
194+
handler: PythonExternalFnHandler,
195+
) -> Self {
196+
self.external_fns = Some(PythonExternalFns { names, handler });
197+
self
142198
}
143199
}
144200

@@ -268,6 +324,7 @@ impl Builtin for Python {
268324
ctx.cwd,
269325
&merged_env,
270326
&self.limits,
327+
self.external_fns.as_ref(),
271328
)
272329
.await
273330
}
@@ -284,6 +341,7 @@ async fn run_python(
284341
cwd: &Path,
285342
env: &HashMap<String, String>,
286343
py_limits: &PythonLimits,
344+
external_fns: Option<&PythonExternalFns>,
287345
) -> Result<ExecResult> {
288346
// Strip shebang if present
289347
let code = if code.starts_with("#!") {
@@ -295,7 +353,8 @@ async fn run_python(
295353
code
296354
};
297355

298-
let runner = match MontyRun::new(code.to_owned(), filename, vec![], vec![]) {
356+
let ext_fn_names = external_fns.map(|ef| ef.names.clone()).unwrap_or_default();
357+
let runner = match MontyRun::new(code.to_owned(), filename, vec![], ext_fn_names) {
299358
Ok(r) => r,
300359
Err(e) => return Ok(format_exception(e)),
301360
};
@@ -343,14 +402,27 @@ async fn run_python(
343402
}
344403
}
345404
}
346-
RunProgress::FunctionCall { state, .. } => {
347-
// No external functions registered; return error
348-
let err = MontyException::new(
349-
ExcType::RuntimeError,
350-
Some("external function not available in virtual mode".into()),
351-
);
405+
RunProgress::FunctionCall {
406+
function_name,
407+
args,
408+
kwargs,
409+
state,
410+
..
411+
} => {
412+
let result = if let Some(ef) = external_fns {
413+
(ef.handler)(function_name, args, kwargs).await
414+
} else {
415+
// No external functions registered; return error
416+
ExternalResult::Error(MontyException::new(
417+
ExcType::RuntimeError,
418+
Some(
419+
"no external function handler configured (external functions not enabled)".into(),
420+
),
421+
))
422+
};
423+
352424
let mut printer = PrintWriter::Collect(buf);
353-
match state.run(ExternalResult::Error(err), &mut printer) {
425+
match state.run(result, &mut printer) {
354426
Ok(next) => {
355427
buf = take_collected(&mut printer);
356428
progress = next;
@@ -1111,4 +1183,165 @@ mod tests {
11111183
assert_eq!(limits.max_memory, 64 * 1024 * 1024);
11121184
assert_eq!(limits.max_recursion, 200);
11131185
}
1186+
1187+
// --- External function tests ---
1188+
1189+
/// Helper: run Python with an external function handler.
1190+
async fn run_with_external(
1191+
code: &str,
1192+
fn_names: &[&str],
1193+
handler: PythonExternalFnHandler,
1194+
) -> ExecResult {
1195+
let args = vec!["-c".to_string(), code.to_string()];
1196+
let env = HashMap::new();
1197+
let mut variables = HashMap::new();
1198+
let mut cwd = PathBuf::from("/home/user");
1199+
let fs = Arc::new(InMemoryFs::new());
1200+
let py = Python::with_limits(PythonLimits::default())
1201+
.with_external_handler(fn_names.iter().map(|s| s.to_string()).collect(), handler);
1202+
let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None);
1203+
py.execute(ctx).await.unwrap()
1204+
}
1205+
1206+
#[tokio::test]
1207+
async fn test_external_fn_return_value() {
1208+
let handler: PythonExternalFnHandler = Arc::new(|_name, _args, _kwargs| {
1209+
Box::pin(async { ExternalResult::Return(MontyObject::Int(42)) })
1210+
});
1211+
let r = run_with_external("print(get_answer())", &["get_answer"], handler).await;
1212+
assert_eq!(r.exit_code, 0);
1213+
assert_eq!(r.stdout, "42\n");
1214+
}
1215+
1216+
#[tokio::test]
1217+
async fn test_external_fn_with_args() {
1218+
let handler: PythonExternalFnHandler = Arc::new(|_name, args, _kwargs| {
1219+
Box::pin(async move {
1220+
let a = match &args[0] {
1221+
MontyObject::Int(i) => *i,
1222+
_ => 0,
1223+
};
1224+
let b = match &args[1] {
1225+
MontyObject::Int(i) => *i,
1226+
_ => 0,
1227+
};
1228+
ExternalResult::Return(MontyObject::Int(a + b))
1229+
})
1230+
});
1231+
let r = run_with_external("print(add(3, 4))", &["add"], handler).await;
1232+
assert_eq!(r.exit_code, 0);
1233+
assert_eq!(r.stdout, "7\n");
1234+
}
1235+
1236+
#[tokio::test]
1237+
async fn test_external_fn_with_kwargs() {
1238+
let handler: PythonExternalFnHandler = Arc::new(|_name, _args, kwargs| {
1239+
Box::pin(async move {
1240+
for (k, v) in &kwargs {
1241+
if let (MontyObject::String(key), MontyObject::String(val)) = (k, v) {
1242+
if key == "name" {
1243+
return ExternalResult::Return(MontyObject::String(format!(
1244+
"hello {val}"
1245+
)));
1246+
}
1247+
}
1248+
}
1249+
ExternalResult::Return(MontyObject::String("hello unknown".into()))
1250+
})
1251+
});
1252+
let r = run_with_external("print(greet(name='world'))", &["greet"], handler).await;
1253+
assert_eq!(r.exit_code, 0);
1254+
assert_eq!(r.stdout, "hello world\n");
1255+
}
1256+
1257+
#[tokio::test]
1258+
async fn test_external_fn_error() {
1259+
let handler: PythonExternalFnHandler = Arc::new(|_name, _args, _kwargs| {
1260+
Box::pin(async {
1261+
ExternalResult::Error(MontyException::new(
1262+
ExcType::RuntimeError,
1263+
Some("something went wrong".into()),
1264+
))
1265+
})
1266+
});
1267+
let r = run_with_external("fail()", &["fail"], handler).await;
1268+
assert_eq!(r.exit_code, 1);
1269+
assert!(r.stderr.contains("RuntimeError"));
1270+
assert!(r.stderr.contains("something went wrong"));
1271+
}
1272+
1273+
#[tokio::test]
1274+
async fn test_external_fn_caught_error() {
1275+
let handler: PythonExternalFnHandler = Arc::new(|_name, _args, _kwargs| {
1276+
Box::pin(async {
1277+
ExternalResult::Error(MontyException::new(
1278+
ExcType::ValueError,
1279+
Some("bad value".into()),
1280+
))
1281+
})
1282+
});
1283+
let r = run_with_external(
1284+
"try:\n fail()\nexcept ValueError as e:\n print(f'caught: {e}')",
1285+
&["fail"],
1286+
handler,
1287+
)
1288+
.await;
1289+
assert_eq!(r.exit_code, 0);
1290+
assert!(r.stdout.contains("caught:"));
1291+
assert!(r.stdout.contains("bad value"));
1292+
}
1293+
1294+
#[tokio::test]
1295+
async fn test_external_fn_multiple_calls() {
1296+
let counter = Arc::new(std::sync::atomic::AtomicI64::new(0));
1297+
let counter_clone = counter.clone();
1298+
let handler: PythonExternalFnHandler = Arc::new(move |_name, _args, _kwargs| {
1299+
let c = counter_clone.clone();
1300+
Box::pin(async move {
1301+
let val = c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1302+
ExternalResult::Return(MontyObject::Int(val))
1303+
})
1304+
});
1305+
let r = run_with_external(
1306+
"a = next_id()\nb = next_id()\nc = next_id()\nprint(a, b, c)",
1307+
&["next_id"],
1308+
handler,
1309+
)
1310+
.await;
1311+
assert_eq!(r.exit_code, 0);
1312+
assert_eq!(r.stdout, "0 1 2\n");
1313+
}
1314+
1315+
#[tokio::test]
1316+
async fn test_external_fn_returns_string() {
1317+
let handler: PythonExternalFnHandler = Arc::new(|_name, args, _kwargs| {
1318+
Box::pin(async move {
1319+
let input = match &args[0] {
1320+
MontyObject::String(s) => s.clone(),
1321+
_ => String::new(),
1322+
};
1323+
ExternalResult::Return(MontyObject::String(input.to_uppercase()))
1324+
})
1325+
});
1326+
let r = run_with_external("print(upper('hello'))", &["upper"], handler).await;
1327+
assert_eq!(r.exit_code, 0);
1328+
assert_eq!(r.stdout, "HELLO\n");
1329+
}
1330+
1331+
#[tokio::test]
1332+
async fn test_external_fn_dispatches_by_name() {
1333+
let handler: PythonExternalFnHandler = Arc::new(|name, _args, _kwargs| {
1334+
Box::pin(async move {
1335+
let result = match name.as_str() {
1336+
"get_x" => MontyObject::Int(10),
1337+
"get_y" => MontyObject::Int(20),
1338+
_ => MontyObject::None,
1339+
};
1340+
ExternalResult::Return(result)
1341+
})
1342+
});
1343+
let r = run_with_external("print(get_x() + get_y())", &["get_x", "get_y"], handler).await;
1344+
assert_eq!(r.exit_code, 0);
1345+
assert_eq!(r.stdout, "30\n");
1346+
}
11141347
}

0 commit comments

Comments
 (0)