#![allow(clippy::not_unsafe_ptr_arg_deref)]
mod schemas;
mod skills;
mod tools;
use std::{
ffi::{CStr, CString},
os::raw::c_char,
sync::{Mutex, OnceLock},
};
pub use aphrodite::state::AphroditeState;
pub(crate) fn shared() -> &'static Mutex<AphroditeState> {
static STATE:OnceLock<Mutex<AphroditeState>> = OnceLock::new();
STATE.get_or_init(|| {
#[cfg(not(test))]
let state = {
let mut s = AphroditeState::default();
aphrodite::config_loader::Config::load().apply_compression(&mut s);
s
};
#[cfg(test)]
let state = AphroditeState::default();
Mutex::new(state)
})
}
pub(crate) fn with_shared<T>(f:impl FnOnce(&mut AphroditeState) -> T) -> T {
let mut guard = shared().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
f(&mut guard)
}
#[cfg(test)]
pub(crate) fn test_guard() -> std::sync::MutexGuard<'static, ()> {
static G:OnceLock<Mutex<()>> = OnceLock::new();
G.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub(crate) fn replacement_from(r:&serde_json::Value) -> serde_json::Value {
if r.get("compressed").and_then(|v| v.as_bool()).unwrap_or(false) {
if let Some(marker) = r.get("marker").and_then(|v| v.as_str()) {
return serde_json::Value::String(marker.to_string());
}
}
serde_json::Value::Null
}
const DEFAULT_CACHE_PORT:u16 = 9797;
const DEFAULT_TOKEN_PORT:u16 = 9798;
fn configured_ports() -> (u16, u16) {
let port_from_env = |var:&str, default:u16| {
match std::env::var(var) {
Ok(v) => {
match v.parse::<u16>() {
Ok(port) => port,
Err(_) => {
eprintln!(
"aphrodite-hermes: {}={:?} is not a valid port (1-65535); using default {}",
var, v, default
);
default
},
}
},
Err(_) => default,
}
};
(
port_from_env("APHRODITE_CACHE_PORT", DEFAULT_CACHE_PORT),
port_from_env("APHRODITE_TOKEN_PORT", DEFAULT_TOKEN_PORT),
)
}
pub(crate) fn proxy_health() -> serde_json::Value {
use std::{net::TcpStream, time::Duration};
let timeout = Duration::from_millis(400);
let alive = |addr:String| {
addr.parse()
.ok()
.and_then(|a| TcpStream::connect_timeout(&a, timeout).ok())
.is_some()
};
let (cache_port, token_port) = configured_ports();
serde_json::json!({
"token": {"port": token_port, "alive": alive(format!("127.0.0.1:{token_port}"))},
"cache": {"port": cache_port, "alive": alive(format!("127.0.0.1:{cache_port}"))},
})
}
unsafe fn cstr_to_string(ptr:*const c_char) -> String {
if ptr.is_null() {
String::new()
} else {
CStr::from_ptr(ptr).to_string_lossy().into_owned()
}
}
fn to_c_string(s:&str) -> *mut c_char { CString::new(s).map(|c| c.into_raw()).unwrap_or(std::ptr::null_mut()) }
fn to_json_error(msg:&str) -> *mut c_char { to_c_string(&serde_json::json!({"error": msg}).to_string()) }
fn guarded(f:impl FnOnce() -> *mut c_char + std::panic::UnwindSafe) -> *mut c_char {
std::panic::catch_unwind(f).unwrap_or_else(|_| to_json_error("internal error: panicked in aphrodite-hermes"))
}
#[no_mangle]
pub extern "C" fn aphrodite_hermes_dispatch_tool(tool_name:*const c_char, args_json:*const c_char) -> *mut c_char {
let name = unsafe { cstr_to_string(tool_name) };
let args = unsafe { cstr_to_string(args_json) };
guarded(std::panic::AssertUnwindSafe(move || {
let result = tools::dispatch(&name, &args);
match serde_json::to_string(&result) {
Ok(json) => to_c_string(&json),
Err(e) => to_json_error(&format!("serialize error: {}", e)),
}
}))
}
#[no_mangle]
pub extern "C" fn aphrodite_hermes_list_tools() -> *mut c_char {
guarded(|| {
let schemas = schemas::all_schemas();
to_c_string(&serde_json::to_string(&schemas).unwrap_or_default())
})
}
#[no_mangle]
pub extern "C" fn aphrodite_hermes_list_skills() -> *mut c_char {
guarded(|| {
let skills = skills::all_skills();
to_c_string(&serde_json::to_string(&skills).unwrap_or_default())
})
}
#[no_mangle]
pub extern "C" fn aphrodite_hermes_get_schema(tool_name:*const c_char) -> *mut c_char {
let name = unsafe { cstr_to_string(tool_name) };
guarded(std::panic::AssertUnwindSafe(move || {
match schemas::get_schema(&name) {
Some(s) => to_c_string(&serde_json::to_string(&s).unwrap_or_default()),
None => to_json_error(&format!("unknown tool: {}", name)),
}
}))
}
#[no_mangle]
pub extern "C" fn aphrodite_hermes_free_string(s:*mut c_char) {
if !s.is_null() {
unsafe {
let _ = CString::from_raw(s);
}
}
}
#[no_mangle]
pub extern "C" fn aphrodite_hermes_version() -> *mut c_char {
guarded(|| to_c_string(&serde_json::json!({"version": env!("CARGO_PKG_VERSION")}).to_string()))
}
#[no_mangle]
pub extern "C" fn aphrodite_hermes_call_hook(hook_name:*const c_char, args_json:*const c_char) -> *mut c_char {
let name = unsafe { cstr_to_string(hook_name) };
let args = unsafe { cstr_to_string(args_json) };
guarded(std::panic::AssertUnwindSafe(move || {
let parsed:serde_json::Value = match serde_json::from_str(&args) {
Ok(v) => v,
Err(e) => return to_json_error(&format!("invalid args: {}", e)),
};
let tool = parsed.get("tool_name").and_then(|v| v.as_str()).unwrap_or("unknown");
let tool_content = parsed
.get("result")
.or_else(|| parsed.get("content"))
.and_then(|v| v.as_str())
.unwrap_or("");
let term_content = parsed
.get("output")
.or_else(|| parsed.get("content"))
.and_then(|v| v.as_str())
.unwrap_or("");
let result:serde_json::Value = with_shared(|state| {
match name.as_str() {
"on_session_start" | "session_start" => aphrodite::session::on_session_start(state),
"pre_tool_call" => {
if state.poll_worker_enabled {
let call_tool = parsed.get("tool_name").and_then(|v| v.as_str()).unwrap_or("unknown");
if call_tool == "terminal" || call_tool == "process" {
let command = parsed.get("args")
.and_then(|a| a.get("command"))
.and_then(|v| v.as_str());
let is_poll = call_tool == "process"
&& parsed.get("args")
.and_then(|a| a.get("action"))
.and_then(|v| v.as_str())
.map(|a| a == "poll")
.unwrap_or(false);
if !is_poll {
if let Some((_task_id, cmd_summary)) =
aphrodite::poll_worker::should_background_pre(command)
{
return serde_json::json!({
"action": "modify",
"args": {
"background": true,
"notify_on_complete": true,
},
"message": format!(
"aphrodite: auto-backgrounding `{}`", cmd_summary
),
});
}
}
}
}
serde_json::Value::Null },
"transform_tool_result" => {
if state.poll_worker_enabled && tool == "process" {
aphrodite::poll_worker::update_from_poll(state, tool, tool_content);
}
let classify = crate::tools::unwrap_hermes_result(tool_content);
let meta = aphrodite::hooks::ToolCallMeta {
args_json:parsed.get("args"),
status:parsed.get("status").and_then(|v| v.as_str()),
error_type:parsed.get("error_type").and_then(|v| v.as_str()),
error_message:parsed.get("error_message").and_then(|v| v.as_str()),
duration_ms:parsed.get("duration_ms").and_then(|v| v.as_u64()),
};
let r = aphrodite::hooks::transform_tool_result_with_meta(
state,
tool_content,
tool,
classify.as_ref().map(|(c, t)| (c.as_str(), t.as_str())),
&meta,
);
replacement_from(&r)
},
"transform_terminal_output" => {
let classify = crate::tools::unwrap_hermes_result(term_content);
let command = parsed.get("command").and_then(|v| v.as_str());
let returncode = parsed.get("returncode").and_then(|v| v.as_i64());
let r = aphrodite::hooks::transform_terminal_output_with_meta(
state,
term_content,
classify.as_ref().map(|(c, t)| (c.as_str(), t.as_str())),
command,
returncode,
);
replacement_from(&r)
},
"pre_llm_call" => {
let context = aphrodite::flow::build_turn_context(state, Some(args.len()));
if context.is_empty() {
serde_json::Value::Null
} else {
serde_json::json!({ "context": context })
}
},
"post_llm_call" => {
aphrodite::hooks::post_llm_call(state);
serde_json::Value::Null
},
_ => serde_json::json!({ "error": format!("unknown hook: {}", name) }),
}
});
to_c_string(&serde_json::to_string(&result).unwrap_or_default())
}))
}
#[no_mangle]
pub extern "C" fn aphrodite_hermes_get_schemas() -> *mut c_char {
guarded(|| {
let schemas = schemas::all_schemas();
to_c_string(&serde_json::json!(schemas).to_string())
})
}
#[no_mangle]
pub extern "C" fn aphrodite_hermes_get_hooks() -> *mut c_char {
guarded(|| {
to_c_string(
&serde_json::json!([
"on_session_start",
"pre_tool_call",
"transform_tool_result",
"transform_terminal_output",
"pre_llm_call",
"post_llm_call"
])
.to_string(),
)
})
}
#[no_mangle]
pub extern "C" fn aphrodite_hermes_proxy_health() -> *mut c_char {
guarded(|| to_c_string(&proxy_health().to_string()))
}
#[cfg(test)]
mod tests {
use std::ffi::CString;
use super::*;
fn env_guard() -> std::sync::MutexGuard<'static, ()> {
static G:std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
G.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
fn test_configured_ports_falls_back_on_malformed_value() {
let _g = env_guard();
std::env::set_var("APHRODITE_CACHE_PORT", "not-a-port");
std::env::remove_var("APHRODITE_TOKEN_PORT");
let (cache, token) = configured_ports();
std::env::remove_var("APHRODITE_CACHE_PORT");
assert_eq!(cache, DEFAULT_CACHE_PORT);
assert_eq!(token, DEFAULT_TOKEN_PORT);
}
#[test]
fn test_configured_ports_honors_valid_override() {
let _g = env_guard();
std::env::set_var("APHRODITE_CACHE_PORT", "19797");
let (cache, _token) = configured_ports();
std::env::remove_var("APHRODITE_CACHE_PORT");
assert_eq!(cache, 19797);
}
#[test]
fn test_version_is_semver() {
let json_ptr = aphrodite_hermes_version();
let json = unsafe { CStr::from_ptr(json_ptr) }.to_string_lossy().into_owned();
let v:serde_json::Value = serde_json::from_str(&json).unwrap();
let ver = v["version"].as_str().unwrap();
assert!(
ver.starts_with("0.") || ver.starts_with("1."),
"expected semver starting with 0. or 1., got: {ver}"
);
aphrodite_hermes_free_string(json_ptr);
}
#[test]
fn test_list_tools_returns_array() {
let json_ptr = aphrodite_hermes_list_tools();
let json = unsafe { CStr::from_ptr(json_ptr) }.to_string_lossy().into_owned();
let v:serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(v.is_array());
assert!(v.as_array().unwrap().len() >= 10);
aphrodite_hermes_free_string(json_ptr);
}
#[test]
fn test_list_skills_returns_array() {
let json_ptr = aphrodite_hermes_list_skills();
let json = unsafe { CStr::from_ptr(json_ptr) }.to_string_lossy().into_owned();
let v:serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(v.is_array());
aphrodite_hermes_free_string(json_ptr);
}
#[test]
fn test_dispatch_unknown_tool() {
let name = CString::new("nonexistent").unwrap();
let args = CString::new("{}").unwrap();
let result_ptr = aphrodite_hermes_dispatch_tool(name.as_ptr(), args.as_ptr());
let result = unsafe { CStr::from_ptr(result_ptr) }.to_string_lossy().into_owned();
assert!(result.contains("error"));
aphrodite_hermes_free_string(result_ptr);
}
#[test]
fn test_call_hook_session_start() {
let _g = crate::test_guard();
let hook = CString::new("session_start").unwrap();
let args = CString::new("{}").unwrap();
let result_ptr = aphrodite_hermes_call_hook(hook.as_ptr(), args.as_ptr());
let result = unsafe { CStr::from_ptr(result_ptr) }.to_string_lossy().into_owned();
let v:serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(v["status"], "ok");
aphrodite_hermes_free_string(result_ptr);
}
#[test]
fn test_call_hook_pre_llm_call_injects_active_directive_context() {
let _g = crate::test_guard();
aphrodite_hermes_call_hook(
CString::new("session_start").unwrap().as_ptr(),
CString::new("{}").unwrap().as_ptr(),
);
with_shared(|state| {
state.directives.insert(
"focus".into(),
aphrodite::directives::Directive { name:"focus".into(), content:"stay concise, 1-2 tools/turn".into() },
);
state.active_directives = vec!["focus".into()];
});
let hook_ptr = aphrodite_hermes_call_hook(
CString::new("pre_llm_call").unwrap().as_ptr(),
CString::new("{}").unwrap().as_ptr(),
);
let result = unsafe { CStr::from_ptr(hook_ptr) }.to_string_lossy().into_owned();
aphrodite_hermes_free_string(hook_ptr);
let v:serde_json::Value = serde_json::from_str(&result).unwrap();
let context = v["context"].as_str().unwrap_or_default();
assert!(
context.contains("[directives: focus]"),
"context missing directive marker: {context}"
);
assert!(context.contains("stay concise"), "context missing directive body: {context}");
with_shared(|state| state.active_directives.clear());
}
#[test]
fn test_call_hook_pre_llm_includes_directives() {
let _g = crate::test_guard();
aphrodite_hermes_call_hook(
CString::new("session_start").unwrap().as_ptr(),
CString::new("{}").unwrap().as_ptr(),
);
with_shared(|state| {
state.directives.insert(
"focus".into(),
aphrodite::directives::Directive { name:"focus".into(), content:"stay targeted, 1-2 tools".into() },
);
state.active_directives = vec!["focus".into()];
});
let hook_ptr = aphrodite_hermes_call_hook(
CString::new("pre_llm_call").unwrap().as_ptr(),
CString::new("{}").unwrap().as_ptr(),
);
let result = unsafe { CStr::from_ptr(hook_ptr) }.to_string_lossy().into_owned();
aphrodite_hermes_free_string(hook_ptr);
let v:serde_json::Value = serde_json::from_str(&result).unwrap();
let context = v["context"].as_str().unwrap_or_default();
assert!(
context.contains("[directives: focus]"),
"assembler must inject directives on the Hermes path: {context}"
);
assert!(context.contains("stay targeted"), "directive body missing: {context}");
with_shared(|state| state.active_directives.clear());
}
#[test]
fn test_call_hook_tool_result_records_error_event() {
let _g = crate::test_guard();
aphrodite_hermes_call_hook(
CString::new("session_start").unwrap().as_ptr(),
CString::new("{}").unwrap().as_ptr(),
);
let args = serde_json::json!({
"tool_name": "terminal",
"result": "error[E0382]: borrow of moved value\n".repeat(50),
"status": "error",
"error_type": "compile_error",
"error_message": "E0382: borrow of moved value",
"args": {"command": "cargo build"},
})
.to_string();
let hook_ptr = aphrodite_hermes_call_hook(
CString::new("transform_tool_result").unwrap().as_ptr(),
CString::new(args).unwrap().as_ptr(),
);
aphrodite_hermes_free_string(hook_ptr);
with_shared(|state| {
let ev = state.tool_events.back().expect("bridge must record a tool event");
assert!(!ev.ok, "status=error must record a failing event");
assert!(ev.error_sig.is_some(), "a failing event must carry an error_sig");
});
}
#[test]
fn test_call_hook_post_llm_call_archives_turn_for_aphrodite_diff() {
let _g = crate::test_guard();
aphrodite_hermes_call_hook(
CString::new("session_start").unwrap().as_ptr(),
CString::new("{}").unwrap().as_ptr(),
);
let compress_args = CString::new(serde_json::json!({"content": "x".repeat(5000)}).to_string()).unwrap();
let compress_ptr = aphrodite_hermes_dispatch_tool(
CString::new("aphrodite_compress").unwrap().as_ptr(),
compress_args.as_ptr(),
);
unsafe { CStr::from_ptr(compress_ptr) }.to_string_lossy().into_owned();
aphrodite_hermes_free_string(compress_ptr);
let hook_ptr = aphrodite_hermes_call_hook(
CString::new("post_llm_call").unwrap().as_ptr(),
CString::new("{}").unwrap().as_ptr(),
);
aphrodite_hermes_free_string(hook_ptr);
let diff_ptr = aphrodite_hermes_dispatch_tool(
CString::new("aphrodite_diff").unwrap().as_ptr(),
CString::new("{}").unwrap().as_ptr(),
);
let diff_result = unsafe { CStr::from_ptr(diff_ptr) }.to_string_lossy().into_owned();
let v:serde_json::Value = serde_json::from_str(&diff_result).unwrap();
aphrodite_hermes_free_string(diff_ptr);
assert_eq!(v["total"], 1, "aphrodite_diff must report the archived turn: {v:?}");
}
#[test]
fn test_call_hook_transform_tool_result_unwraps_hermes_wrapper() {
let _g = crate::test_guard();
aphrodite_hermes_call_hook(
CString::new("session_start").unwrap().as_ptr(),
CString::new("{}").unwrap().as_ptr(),
);
let wrapped = serde_json::json!({"output": "x".repeat(5000), "exit_code": 1}).to_string();
let args = serde_json::json!({"tool_name": "terminal", "result": wrapped}).to_string();
let hook_ptr = aphrodite_hermes_call_hook(
CString::new("transform_tool_result").unwrap().as_ptr(),
CString::new(args).unwrap().as_ptr(),
);
let hook_result = unsafe { CStr::from_ptr(hook_ptr) }.to_string_lossy().into_owned();
let marker_str:String = serde_json::from_str(&hook_result).expect("a marker string, not null");
aphrodite_hermes_free_string(hook_ptr);
assert!(
!marker_str.contains("[json:"),
"hook path must unwrap the Hermes wrapper for preview, got marker: {marker_str}"
);
let (hash, preview) = with_shared(|state| {
let last = state.recent_markers.last().expect("hook must record a marker");
(last.hash.clone(), last.preview.clone())
});
assert!(
!preview.starts_with("[json:"),
"recorded preview must reflect the unwrapped payload: {preview}"
);
let retrieved = crate::tools::dispatch("aphrodite_retrieve", &serde_json::json!({"hash": hash}).to_string());
assert_eq!(retrieved["found"], true);
assert_eq!(
retrieved["content"].as_str().unwrap(),
wrapped,
"retrieve must return the original wrapper losslessly, not just the extracted output"
);
}
#[test]
fn test_get_schema_known_tool() {
let name = CString::new("aphrodite_compress").unwrap();
let result_ptr = aphrodite_hermes_get_schema(name.as_ptr());
let result = unsafe { CStr::from_ptr(result_ptr) }.to_string_lossy().into_owned();
let v:serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(v["name"], "aphrodite_compress");
aphrodite_hermes_free_string(result_ptr);
}
#[test]
fn test_guarded_converts_panic_to_error_json() {
let ptr = guarded(|| panic!("deliberate test panic"));
let json = unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned();
let v:serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(v["error"].as_str().unwrap().contains("panicked"));
aphrodite_hermes_free_string(ptr);
}
#[test]
fn test_call_hook_panic_path_returns_error_not_abort() {
let name = CString::new("aphrodite_compress").unwrap();
let args = CString::new("not json").unwrap();
let ptr = aphrodite_hermes_dispatch_tool(name.as_ptr(), args.as_ptr());
assert!(!ptr.is_null());
aphrodite_hermes_free_string(ptr);
}
#[test]
fn test_pre_tool_call_auto_backgrounds_terminal() {
let _g = crate::test_guard();
aphrodite_hermes_call_hook(
CString::new("session_start").unwrap().as_ptr(),
CString::new("{}").unwrap().as_ptr(),
);
with_shared(|state| state.poll_worker_enabled = true);
let args = serde_json::json!({
"tool_name": "terminal",
"args": {"command": "cargo build --release"},
}).to_string();
let ptr = aphrodite_hermes_call_hook(
CString::new("pre_tool_call").unwrap().as_ptr(),
CString::new(args).unwrap().as_ptr(),
);
let result = unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned();
aphrodite_hermes_free_string(ptr);
let v: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(v["action"], "modify", "pre_tool_call must return modify action: {result}");
assert_eq!(v["args"]["background"], true);
assert_eq!(v["args"]["notify_on_complete"], true);
}
#[test]
fn test_pre_tool_call_does_not_background_poll_action() {
let _g = crate::test_guard();
aphrodite_hermes_call_hook(
CString::new("session_start").unwrap().as_ptr(),
CString::new("{}").unwrap().as_ptr(),
);
with_shared(|state| state.poll_worker_enabled = true);
let args = serde_json::json!({
"tool_name": "process",
"args": {"action": "poll"},
}).to_string();
let ptr = aphrodite_hermes_call_hook(
CString::new("pre_tool_call").unwrap().as_ptr(),
CString::new(args).unwrap().as_ptr(),
);
let result = unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned();
aphrodite_hermes_free_string(ptr);
assert_eq!(result, "null", "process poll must pass through unchanged");
}
#[test]
fn test_pre_tool_call_disabled_passes_through() {
let _g = crate::test_guard();
aphrodite_hermes_call_hook(
CString::new("session_start").unwrap().as_ptr(),
CString::new("{}").unwrap().as_ptr(),
);
with_shared(|state| state.poll_worker_enabled = false);
let args = serde_json::json!({
"tool_name": "terminal",
"args": {"command": "cargo build --release"},
}).to_string();
let ptr = aphrodite_hermes_call_hook(
CString::new("pre_tool_call").unwrap().as_ptr(),
CString::new(args).unwrap().as_ptr(),
);
let result = unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned();
aphrodite_hermes_free_string(ptr);
assert_eq!(result, "null", "disabled flag must pass through: {result}");
}
}