pub(crate) mod claude_code;
pub(crate) mod codex;
pub(crate) mod cursor;
pub(crate) mod hermes;
use axum::http::HeaderMap;
use serde_json::{Map, Value, json};
use uuid::Uuid;
use crate::config::header_string;
use crate::model::{
AgentKind, LlmHintEvent, NormalizedEvent, SessionEvent, SubagentEvent, ToolEvent,
};
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct AdapterOutcome {
pub(crate) events: Vec<NormalizedEvent>,
pub(crate) response: Value,
}
pub(super) struct ClassificationRules<'a> {
kind: AgentKind,
agent_start: &'a [&'a str],
agent_end: &'a [&'a str],
subagent_start: &'a [&'a str],
subagent_end: &'a [&'a str],
tool_start: &'a [&'a str],
tool_end: &'a [&'a str],
}
fn session_id(payload: &Value, headers: &HeaderMap) -> String {
header_string(headers, "x-nemo-relay-session-id")
.or_else(|| header_string(headers, "x-claude-code-session-id"))
.or_else(|| session_id_from_payload(payload))
.unwrap_or_else(|| format!("hook-{}", Uuid::now_v7()))
}
fn session_id_from_payload(payload: &Value) -> Option<String> {
[
&["session_id"][..],
&["sessionId"],
&["session", "id"],
&["conversation_id"],
&["conversationId"],
&["parent_session_id"],
&["task_id"],
&["extra", "session_id"],
&["extra", "task_id"],
]
.into_iter()
.find_map(|path| string_at(payload, path))
}
fn event_name(payload: &Value) -> String {
string_at(payload, &["hook_event_name"])
.or_else(|| string_at(payload, &["event_name"]))
.or_else(|| string_at(payload, &["eventName"]))
.or_else(|| string_at(payload, &["event"]))
.or_else(|| string_at(payload, &["type"]))
.or_else(|| string_at(payload, &["name"]))
.or_else(|| string_at(payload, &["extra", "hook_event_name"]))
.or_else(|| string_at(payload, &["extra", "event_name"]))
.or_else(|| string_at(payload, &["extra", "eventName"]))
.or_else(|| string_at(payload, &["extra", "event"]))
.or_else(|| string_at(payload, &["extra", "type"]))
.or_else(|| string_at(payload, &["extra", "name"]))
.unwrap_or_else(|| "unknown".to_string())
}
fn metadata(payload: &Value, headers: &HeaderMap, kind: AgentKind, event_name: &str) -> Value {
let mut object = Map::new();
object.insert("agent_kind".into(), json!(kind.as_str()));
object.insert("hook_event_name".into(), json!(event_name));
if let Some(profile) = header_string(headers, "x-nemo-relay-config-profile") {
object.insert("gateway_config_profile".into(), json!(profile));
}
for (key, value) in [
("cwd", string_at(payload, &["cwd"])),
("transcript_path", string_at(payload, &["transcript_path"])),
("project_dir", string_at(payload, &["project_dir"])),
("user_email", string_at(payload, &["user_email"])),
("model", string_at(payload, &["model"])),
("agent_id", string_at(payload, &["agent_id"])),
("agent_type", string_at(payload, &["agent_type"])),
] {
if let Some(value) = value {
object.insert(key.into(), json!(value));
}
}
Value::Object(object)
}
pub(crate) fn common_session_event(
payload: &Value,
headers: &HeaderMap,
kind: AgentKind,
) -> SessionEvent {
let event_name = event_name(payload);
SessionEvent {
session_id: session_id(payload, headers),
agent_kind: kind,
event_name: event_name.clone(),
payload: payload.clone(),
metadata: metadata(payload, headers, kind, &event_name),
}
}
fn common_subagent_event(payload: &Value, headers: &HeaderMap, kind: AgentKind) -> SubagentEvent {
let session = common_session_event(payload, headers, kind);
let subagent_id = subagent_id(payload)
.or_else(|| header_string(headers, "x-nemo-relay-subagent-id"))
.unwrap_or_else(|| "subagent".to_string());
SubagentEvent {
session_id: session.session_id,
agent_kind: kind,
event_name: session.event_name,
subagent_id,
payload: session.payload,
metadata: session.metadata,
}
}
fn common_llm_hint_event(payload: &Value, headers: &HeaderMap, kind: AgentKind) -> LlmHintEvent {
let session = common_session_event(payload, headers, kind);
LlmHintEvent {
session_id: session.session_id,
agent_kind: kind,
event_name: session.event_name,
subagent_id: hook_subagent_id(payload, headers),
agent_id: first_string_at(payload, &[&["agent_id"][..], &["agent", "id"][..]]),
agent_type: first_string_at(
payload,
&[
&["agent_type"][..],
&["agent", "type"][..],
&["agent", "name"][..],
],
),
conversation_id: first_string_at(
payload,
&[
&["conversation_id"][..],
&["conversationId"][..],
&["conversation", "id"][..],
],
),
generation_id: first_string_at(
payload,
&[
&["generation_id"][..],
&["generationId"][..],
&["generation", "id"][..],
],
),
request_id: first_string_at(
payload,
&[
&["request_id"][..],
&["requestId"][..],
&["request", "id"][..],
&["extra", "request_id"][..],
],
),
model: first_string_at(
payload,
&[&["model"][..], &["model_name"][..], &["modelName"][..]],
),
payload: session.payload,
metadata: session.metadata,
}
}
fn common_tool_event(payload: &Value, headers: &HeaderMap, kind: AgentKind) -> ToolEvent {
let session = common_session_event(payload, headers, kind);
let normalized_event = normalize_name(&session.event_name);
ToolEvent {
session_id: session.session_id,
agent_kind: kind,
event_name: session.event_name,
tool_call_id: tool_call_id(payload),
tool_name: tool_name(payload),
subagent_id: hook_subagent_id(payload, headers),
arguments: tool_arguments(payload),
result: tool_result(payload, &normalized_event),
status: tool_status(payload, &normalized_event),
payload: session.payload,
metadata: session.metadata,
}
}
fn first_string_at(payload: &Value, paths: &[&[&str]]) -> Option<String> {
paths.iter().find_map(|path| string_at(payload, path))
}
fn hook_subagent_id(payload: &Value, headers: &HeaderMap) -> Option<String> {
subagent_id(payload).or_else(|| header_string(headers, "x-nemo-relay-subagent-id"))
}
fn tool_call_id(payload: &Value) -> String {
first_string_at(
payload,
&[
&["tool_call_id"][..],
&["toolCallId"][..],
&["tool_use_id"][..],
&["call_id"][..],
&["extra", "tool_call_id"][..],
&["extra", "call_id"][..],
&["tool", "id"][..],
&["tool_input", "id"][..],
&["id"][..],
],
)
.unwrap_or_else(|| format!("tool-{}", Uuid::now_v7()))
}
fn tool_name(payload: &Value) -> String {
first_string_at(
payload,
&[
&["tool_name"][..],
&["toolName"][..],
&["tool", "name"][..],
&["tool_input", "name"][..],
&["name"][..],
],
)
.unwrap_or_else(|| "unknown_tool".to_string())
}
fn tool_arguments(payload: &Value) -> Value {
value_at(payload, &["tool_input"])
.or_else(|| value_at(payload, &["input"]))
.or_else(|| value_at(payload, &["arguments"]))
.or_else(|| value_at(payload, &["args"]))
.unwrap_or(Value::Null)
}
fn tool_result(payload: &Value, normalized_event: &str) -> Value {
value_at(payload, &["tool_output"])
.or_else(|| value_at(payload, &["tool_response"]))
.or_else(|| value_at(payload, &["output"]))
.or_else(|| value_at(payload, &["result"]))
.or_else(|| value_at(payload, &["extra", "tool_output"]))
.or_else(|| value_at(payload, &["extra", "result"]))
.or_else(|| event_detail_result(payload, normalized_event))
.unwrap_or(Value::Null)
}
fn tool_status(payload: &Value, normalized_event: &str) -> Option<String> {
first_string_at(
payload,
&[&["status"][..], &["decision"][..], &["permission"][..]],
)
.or_else(|| {
(normalized_event.contains("failure") || normalized_event.contains("failed"))
.then_some("error".to_string())
})
.or_else(|| {
normalized_event
.contains("permissiondenied")
.then_some("denied".to_string())
})
}
fn subagent_id(payload: &Value) -> Option<String> {
string_at(payload, &["subagent_id"])
.or_else(|| string_at(payload, &["subagentId"]))
.or_else(|| string_at(payload, &["agent_id"]))
.or_else(|| string_at(payload, &["subagent", "id"]))
.or_else(|| string_at(payload, &["agent", "id"]))
.or_else(|| string_at(payload, &["extra", "subagent_id"]))
.or_else(|| string_at(payload, &["extra", "subagentId"]))
.or_else(|| string_at(payload, &["extra", "agent_id"]))
.or_else(|| string_at(payload, &["extra", "subagent", "id"]))
.or_else(|| string_at(payload, &["extra", "agent", "id"]))
}
fn event_detail_result(payload: &Value, normalized_event: &str) -> Option<Value> {
let include_details = normalized_event.contains("failure")
|| normalized_event.contains("failed")
|| normalized_event.contains("permissiondenied");
if !include_details {
return None;
}
let mut object = Map::new();
for key in ["error", "reason", "is_interrupt", "duration_ms"] {
if let Some(value) = value_at(payload, &[key]) {
object.insert(key.into(), value);
}
}
(!object.is_empty()).then_some(Value::Object(object))
}
fn string_at(payload: &Value, path: &[&str]) -> Option<String> {
value_at(payload, path)
.and_then(|value| match value {
Value::String(value) => Some(value),
Value::Number(value) => Some(value.to_string()),
Value::Bool(value) => Some(value.to_string()),
_ => None,
})
.filter(|value| !value.is_empty())
}
fn value_at(payload: &Value, path: &[&str]) -> Option<Value> {
let mut current = payload;
for key in path {
current = current.get(*key)?;
}
Some(current.clone())
}
fn classify(
payload: &Value,
headers: &HeaderMap,
rules: &ClassificationRules<'_>,
) -> Vec<NormalizedEvent> {
let normalized = normalize_name(&event_name(payload));
if matches!(
normalized.as_str(),
"beforesubmitprompt" | "promptsubmitted" | "userpromptsubmit"
) {
return vec![
NormalizedEvent::PromptSubmitted(common_session_event(payload, headers, rules.kind)),
NormalizedEvent::LlmHint(common_llm_hint_event(payload, headers, rules.kind)),
];
}
let primary = classify_primary(payload, headers, rules);
if normalized == "stop" && !primary.is_terminal() {
return vec![
primary,
NormalizedEvent::TurnEnded(common_session_event(payload, headers, rules.kind)),
];
}
vec![primary]
}
fn classify_primary(
payload: &Value,
headers: &HeaderMap,
rules: &ClassificationRules<'_>,
) -> NormalizedEvent {
let event = event_name(payload);
let normalized = normalize_name(&event);
if rules
.agent_start
.iter()
.any(|name| normalize_name(name) == normalized)
{
NormalizedEvent::AgentStarted(common_session_event(payload, headers, rules.kind))
} else if rules
.agent_end
.iter()
.any(|name| normalize_name(name) == normalized)
{
NormalizedEvent::AgentEnded(common_session_event(payload, headers, rules.kind))
} else if rules
.subagent_start
.iter()
.any(|name| normalize_name(name) == normalized)
{
NormalizedEvent::SubagentStarted(common_subagent_event(payload, headers, rules.kind))
} else if rules
.subagent_end
.iter()
.any(|name| normalize_name(name) == normalized)
{
NormalizedEvent::SubagentEnded(common_subagent_event(payload, headers, rules.kind))
} else if rules
.tool_start
.iter()
.any(|name| normalize_name(name) == normalized)
{
NormalizedEvent::ToolStarted(common_tool_event(payload, headers, rules.kind))
} else if rules
.tool_end
.iter()
.any(|name| normalize_name(name) == normalized)
{
NormalizedEvent::ToolEnded(common_tool_event(payload, headers, rules.kind))
} else {
match normalized.as_str() {
"afteragentresponse" | "agentresponse" | "assistantresponse" | "afteragentthought"
| "prellmcall" | "postllmcall" | "stop" => {
NormalizedEvent::LlmHint(common_llm_hint_event(payload, headers, rules.kind))
}
"precompact" | "compaction" => {
NormalizedEvent::Compaction(common_session_event(payload, headers, rules.kind))
}
"notification" => {
NormalizedEvent::Notification(common_session_event(payload, headers, rules.kind))
}
_ => NormalizedEvent::HookMark(common_session_event(payload, headers, rules.kind)),
}
}
}
fn normalize_name(name: &str) -> String {
name.chars()
.filter(|character| character.is_ascii_alphanumeric())
.flat_map(char::to_lowercase)
.collect()
}
#[cfg(test)]
#[path = "../../tests/coverage/adapters_tests.rs"]
mod tests;