use base64::{Engine as _, engine::general_purpose::STANDARD};
use pyo3::prelude::*;
pub(crate) const MAX_DECODE_DEPTH: usize = 64;
pub const PYTHON_AGENT_INIT_SCRIPT: &str = include_str!("py/agent_init.py");
pub(crate) fn import_serialized<'py>(py: Python<'py>, module: &str) -> PyResult<Bound<'py, PyAny>> {
static FIRST_IMPORT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
if is_in_sys_modules(py, module)? {
return py.import(module).map(Bound::into_any);
}
py.detach(|| {
let _guard = FIRST_IMPORT_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Python::attach(|py| {
match is_in_sys_modules(py, module) {
Ok(true) => {}
Ok(false) => {
if let Err(e) = py.import(module) {
tracing::debug!(module, error = %e, "serialized first import failed");
}
}
Err(e) => {
tracing::debug!(
module,
error = %e,
"sys.modules probe failed during serialized import",
);
}
}
});
});
py.import(module).map(Bound::into_any)
}
fn is_in_sys_modules(py: Python<'_>, module: &str) -> PyResult<bool> {
py.import("sys")?.getattr("modules")?.contains(module)
}
pub(crate) fn warm_up_lazy_imports(py: Python<'_>) {
if let Err(e) = import_serialized(py, "collections.abc") {
tracing::warn!(
error = %e,
"failed to pre-import collections.abc during Python warm-up",
);
}
}
pub fn decode_prompt_py<'py>(py: Python<'py>, prompt_str: &str) -> PyResult<Bound<'py, PyAny>> {
match serde_json::from_str::<serde_json::Value>(prompt_str) {
Ok(value) => decode_content_value(py, &value, 0),
Err(e) => {
tracing::trace!(error = %e, "Prompt is plain text (not JSON), treating as simple string");
Ok(pyo3::types::PyString::new(py, prompt_str).into_any())
}
}
}
pub fn to_dict_py<'py>(ob: &Bound<'py, PyAny>) -> PyResult<Bound<'py, PyAny>> {
if ob.hasattr("model_dump")? {
ob.call_method0("model_dump")
} else if ob.hasattr("dict")? {
ob.call_method0("dict")
} else {
Ok(ob.clone())
}
}
fn decode_content_value<'py>(
py: Python<'py>,
value: &serde_json::Value,
depth: usize,
) -> PyResult<Bound<'py, PyAny>> {
if depth > MAX_DECODE_DEPTH {
return Err(pyo3::exceptions::PyValueError::new_err(
"JSON nesting depth exceeded maximum allowed depth",
));
}
match value {
serde_json::Value::String(s) => Ok(pyo3::types::PyString::new(py, s.as_str()).into_any()),
serde_json::Value::Array(arr) => {
let py_list = pyo3::types::PyList::empty(py);
for item in arr {
py_list.append(decode_content_value(py, item, depth + 1)?)?;
}
Ok(py_list.into_any())
}
serde_json::Value::Object(map) => {
if let Some(serde_json::Value::String(typ)) = map.get("type")
&& matches!(typ.as_str(), "Image" | "Document" | "Audio" | "Video")
{
let data_b64 = map.get("data").and_then(|v| v.as_str()).ok_or_else(|| {
pyo3::exceptions::PyValueError::new_err(format!(
"{typ} content is missing required 'data' field"
))
})?;
let raw_bytes = STANDARD.decode(data_b64).map_err(|e| {
pyo3::exceptions::PyValueError::new_err(format!("Invalid base64: {e}"))
})?;
let mime_type = map
.get("mime_type")
.and_then(|v| v.as_str())
.ok_or_else(|| {
pyo3::exceptions::PyValueError::new_err(format!(
"{typ} content is missing required 'mime_type' field"
))
})?;
let types_mod = import_serialized(py, "google.antigravity.types")?;
let kwargs = pyo3::types::PyDict::new(py);
kwargs.set_item("data", pyo3::types::PyBytes::new(py, &raw_bytes))?;
kwargs.set_item("mime_type", mime_type)?;
if let Some(serde_json::Value::String(desc)) = map.get("description") {
kwargs.set_item("description", desc)?;
}
let obj = types_mod.getattr(typ.as_str())?.call((), Some(&kwargs))?;
return Ok(obj);
}
let py_dict = pyo3::types::PyDict::new(py);
for (k, v) in map {
py_dict.set_item(k, decode_content_value(py, v, depth + 1)?)?;
}
Ok(py_dict.into_any())
}
_ => {
warm_up_lazy_imports(py);
let obj = pythonize::pythonize(py, value).map_err(|e| {
pyo3::exceptions::PyValueError::new_err(format!("Serialization failed: {e}"))
})?;
Ok(obj)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_content_value_string() {
Python::attach(|py| {
let result = decode_prompt_py(py, "hello").unwrap();
let s: String = result.extract().unwrap();
assert_eq!(s, "hello");
});
}
#[test]
fn decode_content_value_json_string() {
Python::attach(|py| {
let result = decode_prompt_py(py, r#""hello world""#).unwrap();
let s: String = result.extract().unwrap();
assert_eq!(s, "hello world");
});
}
#[test]
fn decode_content_value_array() {
Python::attach(|py| {
let result = decode_prompt_py(py, r#"["a", "b"]"#).unwrap();
let list = result.cast::<pyo3::types::PyList>().unwrap();
assert_eq!(list.len(), 2);
});
}
#[test]
fn decode_content_value_depth_limit() {
let depth = MAX_DECODE_DEPTH + 10;
let mut json = String::new();
for _ in 0..depth {
json.push('[');
}
json.push_str("\"leaf\"");
for _ in 0..depth {
json.push(']');
}
Python::attach(|py| {
let result = decode_prompt_py(py, &json);
assert!(result.is_err(), "should fail with depth exceeded");
let err_str = format!("{}", result.unwrap_err());
assert!(
err_str.contains("nesting") || err_str.contains("depth"),
"error should mention depth: {err_str}"
);
});
}
#[test]
fn decode_content_value_plain_text_fallback() {
Python::attach(|py| {
let result = decode_prompt_py(py, "not json { at all").unwrap();
let s: String = result.extract().unwrap();
assert_eq!(s, "not json { at all");
});
}
}