pub(crate) mod claude_code;
pub(crate) mod codex;
pub(crate) mod hermes;
use axum::http::HeaderMap;
use serde_json::{Map, Value, json};
use uuid::Uuid;
use crate::config::header_string;
use crate::json_path::{
string_at, string_at_any as first_string_at, value_at, value_at_any as first_value_at,
};
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],
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct ExtractedLlmHint {
pub(crate) subagent_id: Option<String>,
pub(crate) agent_id: Option<String>,
pub(crate) agent_type: Option<String>,
pub(crate) conversation_id: Option<String>,
pub(crate) generation_id: Option<String>,
pub(crate) request_id: Option<String>,
pub(crate) model: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ExtractedToolCall {
pub(crate) tool_call_id: Option<String>,
pub(crate) tool_name: Option<String>,
pub(crate) subagent_id: Option<String>,
pub(crate) arguments: Option<Value>,
pub(crate) result: Option<Value>,
pub(crate) status: Option<String>,
}
pub(crate) trait AgentPayloadExtractor {
fn session_header_policy(&self) -> SessionHeaderPolicy {
SessionHeaderPolicy::RelayAndClaude
}
fn session_id_paths(&self) -> &'static [&'static [&'static str]] {
SESSION_ID_PATHS
}
fn event_name_paths(&self) -> &'static [&'static [&'static str]] {
EVENT_NAME_PATHS
}
fn subagent_id_paths(&self) -> &'static [&'static [&'static str]] {
SUBAGENT_ID_PATHS
}
fn tool_paths(&self) -> &'static ToolPathSet {
TOOL_PATHS
}
fn session_id(&self, payload: &Value, headers: &HeaderMap) -> Option<String> {
agent_session_id(
headers,
payload,
self.session_header_policy(),
self.session_id_paths(),
)
}
fn event_name(&self, payload: &Value) -> Option<String> {
first_string_at(payload, self.event_name_paths())
}
fn metadata(
&self,
payload: &Value,
headers: &HeaderMap,
kind: AgentKind,
event_name: &str,
) -> Value {
agent_metadata(payload, headers, kind, event_name)
}
fn subagent_id(&self, payload: &Value, headers: &HeaderMap) -> Option<String> {
agent_subagent_id(payload, headers, self.subagent_id_paths())
}
fn llm_hint(&self, payload: &Value, headers: &HeaderMap) -> ExtractedLlmHint {
agent_llm_hint(payload, self.subagent_id(payload, headers))
}
fn tool_call(
&self,
payload: &Value,
headers: &HeaderMap,
event_name: &str,
) -> ExtractedToolCall {
agent_tool_call(
payload,
self.subagent_id(payload, headers),
event_name,
self.tool_paths(),
)
}
}
pub(super) struct ClaudeCodePayloadExtractor;
pub(super) struct CodexPayloadExtractor;
pub(super) struct HermesPayloadExtractor;
pub(super) static CLAUDE_CODE_PAYLOAD_EXTRACTOR: ClaudeCodePayloadExtractor =
ClaudeCodePayloadExtractor;
pub(super) static CODEX_PAYLOAD_EXTRACTOR: CodexPayloadExtractor = CodexPayloadExtractor;
pub(super) static HERMES_PAYLOAD_EXTRACTOR: HermesPayloadExtractor = HermesPayloadExtractor;
impl AgentPayloadExtractor for ClaudeCodePayloadExtractor {
fn tool_paths(&self) -> &'static ToolPathSet {
CLAUDE_TOOL_PATHS
}
}
impl AgentPayloadExtractor for CodexPayloadExtractor {
fn session_header_policy(&self) -> SessionHeaderPolicy {
SessionHeaderPolicy::RelayOnly
}
fn subagent_id_paths(&self) -> &'static [&'static [&'static str]] {
CODEX_SUBAGENT_ID_PATHS
}
fn tool_paths(&self) -> &'static ToolPathSet {
CODEX_TOOL_PATHS
}
}
impl AgentPayloadExtractor for HermesPayloadExtractor {
fn subagent_id_paths(&self) -> &'static [&'static [&'static str]] {
HERMES_SUBAGENT_ID_PATHS
}
}
pub(crate) struct ToolPathSet {
call_id: &'static [&'static [&'static str]],
name: &'static [&'static [&'static str]],
arguments: &'static [&'static [&'static str]],
result: &'static [&'static [&'static str]],
status: &'static [&'static [&'static str]],
}
#[derive(Clone, Copy)]
pub(crate) enum SessionHeaderPolicy {
RelayOnly,
RelayAndClaude,
}
const SESSION_ID_PATHS: &[&[&str]] = &[
&["session_id"],
&["sessionId"],
&["session", "id"],
&["conversation_id"],
&["conversationId"],
&["parent_session_id"],
&["task_id"],
&["extra", "session_id"],
&["extra", "task_id"],
];
const EVENT_NAME_PATHS: &[&[&str]] = &[
&["hook_event_name"],
&["event_name"],
&["eventName"],
&["event"],
&["type"],
&["name"],
&["extra", "hook_event_name"],
&["extra", "event_name"],
&["extra", "eventName"],
&["extra", "event"],
&["extra", "type"],
&["extra", "name"],
];
const SUBAGENT_ID_PATHS: &[&[&str]] = &[
&["subagent_id"],
&["subagentId"],
&["child_subagent_id"],
&["childSubagentId"],
&["agent_id"],
&["subagent", "id"],
&["agent", "id"],
&["extra", "subagent_id"],
&["extra", "subagentId"],
&["extra", "child_subagent_id"],
&["extra", "childSubagentId"],
&["extra", "agent_id"],
&["extra", "subagent", "id"],
&["extra", "agent", "id"],
];
const CODEX_SUBAGENT_ID_PATHS: &[&[&str]] = &[
&["subagent_id"],
&["subagentId"],
&["child_subagent_id"],
&["childSubagentId"],
&["agent_id"],
&["source", "subagent", "thread_spawn", "agent_nickname"],
&["subagent", "id"],
&["agent", "id"],
&["extra", "subagent_id"],
&["extra", "subagentId"],
&["extra", "child_subagent_id"],
&["extra", "childSubagentId"],
&["extra", "agent_id"],
&["extra", "subagent", "id"],
&["extra", "agent", "id"],
];
const HERMES_SUBAGENT_ID_PATHS: &[&[&str]] = &[
&["child_subagent_id"],
&["childSubagentId"],
&["subagent_id"],
&["subagentId"],
&["agent_id"],
&["subagent", "id"],
&["agent", "id"],
&["extra", "child_subagent_id"],
&["extra", "childSubagentId"],
&["extra", "subagent_id"],
&["extra", "subagentId"],
&["extra", "agent_id"],
&["extra", "subagent", "id"],
&["extra", "agent", "id"],
];
const CLAUDE_TOOL_CALL_ID_PATHS: &[&[&str]] = &[
&["tool_use_id"],
&["tool_call_id"],
&["toolCallId"],
&["call_id"],
&["extra", "tool_call_id"],
&["extra", "call_id"],
&["tool", "id"],
&["tool_input", "id"],
&["id"],
];
const TOOL_CALL_ID_PATHS: &[&[&str]] = &[
&["tool_call_id"],
&["toolCallId"],
&["tool_use_id"],
&["call_id"],
&["extra", "tool_call_id"],
&["extra", "call_id"],
&["tool", "id"],
&["tool_input", "id"],
&["id"],
];
const TOOL_NAME_PATHS: &[&[&str]] = &[
&["tool_name"],
&["toolName"],
&["tool", "name"],
&["tool_input", "name"],
&["name"],
];
const TOOL_ARGUMENT_PATHS: &[&[&str]] = &[&["tool_input"], &["input"], &["arguments"], &["args"]];
const CODEX_TOOL_ARGUMENT_PATHS: &[&[&str]] =
&[&["arguments"], &["args"], &["input"], &["tool_input"]];
const TOOL_RESULT_PATHS: &[&[&str]] = &[
&["tool_output"],
&["tool_response"],
&["output"],
&["result"],
&["extra", "tool_output"],
&["extra", "result"],
];
const TOOL_STATUS_PATHS: &[&[&str]] = &[&["status"], &["decision"], &["permission"]];
const TOOL_PATHS: &ToolPathSet = &ToolPathSet {
call_id: TOOL_CALL_ID_PATHS,
name: TOOL_NAME_PATHS,
arguments: TOOL_ARGUMENT_PATHS,
result: TOOL_RESULT_PATHS,
status: TOOL_STATUS_PATHS,
};
const CLAUDE_TOOL_PATHS: &ToolPathSet = &ToolPathSet {
call_id: CLAUDE_TOOL_CALL_ID_PATHS,
name: TOOL_NAME_PATHS,
arguments: TOOL_ARGUMENT_PATHS,
result: TOOL_RESULT_PATHS,
status: TOOL_STATUS_PATHS,
};
const CODEX_TOOL_PATHS: &ToolPathSet = &ToolPathSet {
call_id: TOOL_CALL_ID_PATHS,
name: TOOL_NAME_PATHS,
arguments: CODEX_TOOL_ARGUMENT_PATHS,
result: TOOL_RESULT_PATHS,
status: TOOL_STATUS_PATHS,
};
fn agent_session_id(
headers: &HeaderMap,
payload: &Value,
header_policy: SessionHeaderPolicy,
payload_paths: &'static [&'static [&'static str]],
) -> Option<String> {
header_string(headers, "x-nemo-relay-session-id")
.or_else(|| match header_policy {
SessionHeaderPolicy::RelayOnly => None,
SessionHeaderPolicy::RelayAndClaude => {
header_string(headers, "x-claude-code-session-id")
}
})
.or_else(|| session_id_from_payload(payload, payload_paths))
}
fn agent_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 [
("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)
}
fn agent_subagent_id(
payload: &Value,
headers: &HeaderMap,
paths: &'static [&'static [&'static str]],
) -> Option<String> {
first_string_at(payload, paths).or_else(|| header_string(headers, "x-nemo-relay-subagent-id"))
}
fn agent_llm_hint(payload: &Value, subagent_id: Option<String>) -> ExtractedLlmHint {
ExtractedLlmHint {
subagent_id,
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"][..]],
),
}
}
fn agent_tool_call(
payload: &Value,
subagent_id: Option<String>,
event_name: &str,
paths: &ToolPathSet,
) -> ExtractedToolCall {
let normalized_event = normalize_name(event_name);
ExtractedToolCall {
tool_call_id: first_string_at(payload, paths.call_id),
tool_name: first_string_at(payload, paths.name),
subagent_id,
arguments: first_value_at(payload, paths.arguments),
result: first_value_at(payload, paths.result)
.or_else(|| event_detail_result(payload, &normalized_event)),
status: first_string_at(payload, paths.status)
.or_else(|| derived_tool_status(&normalized_event)),
}
}
fn session_id(
payload: &Value,
headers: &HeaderMap,
extractor: &dyn AgentPayloadExtractor,
) -> String {
let fallback_session_id = fallback_session_id();
session_id_with_fallback(payload, headers, extractor, &fallback_session_id)
}
fn fallback_session_id() -> String {
format!("hook-{}", Uuid::now_v7())
}
fn session_id_with_fallback(
payload: &Value,
headers: &HeaderMap,
extractor: &dyn AgentPayloadExtractor,
fallback_session_id: &str,
) -> String {
extractor
.session_id(payload, headers)
.unwrap_or_else(|| fallback_session_id.to_string())
}
fn session_id_from_payload(
payload: &Value,
paths: &'static [&'static [&'static str]],
) -> Option<String> {
first_string_at(payload, paths)
}
fn event_name(payload: &Value, extractor: &dyn AgentPayloadExtractor) -> String {
extractor
.event_name(payload)
.unwrap_or_else(|| "unknown".to_string())
}
fn metadata(
payload: &Value,
headers: &HeaderMap,
kind: AgentKind,
event_name: &str,
extractor: &dyn AgentPayloadExtractor,
) -> Value {
extractor.metadata(payload, headers, kind, event_name)
}
pub(crate) fn common_session_event(
payload: &Value,
headers: &HeaderMap,
kind: AgentKind,
extractor: &dyn AgentPayloadExtractor,
) -> SessionEvent {
let fallback_session_id = fallback_session_id();
common_session_event_with_fallback(payload, headers, kind, extractor, &fallback_session_id)
}
fn common_session_event_with_fallback(
payload: &Value,
headers: &HeaderMap,
kind: AgentKind,
extractor: &dyn AgentPayloadExtractor,
fallback_session_id: &str,
) -> SessionEvent {
let event_name = event_name(payload, extractor);
SessionEvent {
session_id: session_id_with_fallback(payload, headers, extractor, fallback_session_id),
agent_kind: kind,
event_name: event_name.clone(),
payload: payload.clone(),
metadata: metadata(payload, headers, kind, &event_name, extractor),
}
}
fn common_subagent_event_with_fallback(
payload: &Value,
headers: &HeaderMap,
kind: AgentKind,
extractor: &dyn AgentPayloadExtractor,
fallback_session_id: &str,
) -> SubagentEvent {
let session =
common_session_event_with_fallback(payload, headers, kind, extractor, fallback_session_id);
let subagent_id = extractor
.subagent_id(payload, headers)
.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_with_fallback(
payload: &Value,
headers: &HeaderMap,
kind: AgentKind,
extractor: &dyn AgentPayloadExtractor,
fallback_session_id: &str,
) -> LlmHintEvent {
let session =
common_session_event_with_fallback(payload, headers, kind, extractor, fallback_session_id);
let hint = extractor.llm_hint(payload, headers);
LlmHintEvent {
session_id: session.session_id,
agent_kind: kind,
event_name: session.event_name,
subagent_id: hint.subagent_id,
agent_id: hint.agent_id,
agent_type: hint.agent_type,
conversation_id: hint.conversation_id,
generation_id: hint.generation_id,
request_id: hint.request_id,
model: hint.model,
payload: session.payload,
metadata: session.metadata,
}
}
fn common_tool_event_with_fallback(
payload: &Value,
headers: &HeaderMap,
kind: AgentKind,
extractor: &dyn AgentPayloadExtractor,
fallback_session_id: &str,
) -> ToolEvent {
let session =
common_session_event_with_fallback(payload, headers, kind, extractor, fallback_session_id);
let tool_call = extractor.tool_call(payload, headers, &session.event_name);
ToolEvent {
session_id: session.session_id,
agent_kind: kind,
event_name: session.event_name,
tool_call_id: tool_call
.tool_call_id
.unwrap_or_else(|| format!("tool-{}", Uuid::now_v7())),
tool_name: tool_call
.tool_name
.unwrap_or_else(|| "unknown_tool".to_string()),
subagent_id: tool_call.subagent_id,
arguments: tool_call.arguments.unwrap_or(Value::Null),
result: tool_call.result.unwrap_or(Value::Null),
status: tool_call.status,
payload: session.payload,
metadata: session.metadata,
}
}
fn derived_tool_status(normalized_event: &str) -> Option<String> {
{
(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 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 classify(
payload: &Value,
headers: &HeaderMap,
extractor: &dyn AgentPayloadExtractor,
rules: &ClassificationRules<'_>,
) -> Vec<NormalizedEvent> {
let fallback_session_id = fallback_session_id();
let normalized = normalize_name(&event_name(payload, extractor));
if matches!(
normalized.as_str(),
"beforesubmitprompt" | "promptsubmitted" | "userpromptsubmit"
) {
return vec![
NormalizedEvent::PromptSubmitted(common_session_event_with_fallback(
payload,
headers,
rules.kind,
extractor,
&fallback_session_id,
)),
NormalizedEvent::LlmHint(common_llm_hint_event_with_fallback(
payload,
headers,
rules.kind,
extractor,
&fallback_session_id,
)),
];
}
let primary = classify_primary(payload, headers, extractor, rules, &fallback_session_id);
if normalized == "stop" && !primary.is_terminal() {
return vec![
primary,
NormalizedEvent::TurnEnded(common_session_event_with_fallback(
payload,
headers,
rules.kind,
extractor,
&fallback_session_id,
)),
];
}
vec![primary]
}
fn classify_primary(
payload: &Value,
headers: &HeaderMap,
extractor: &dyn AgentPayloadExtractor,
rules: &ClassificationRules<'_>,
fallback_session_id: &str,
) -> NormalizedEvent {
let event = event_name(payload, extractor);
let normalized = normalize_name(&event);
if rules
.agent_start
.iter()
.any(|name| normalize_name(name) == normalized)
{
NormalizedEvent::AgentStarted(common_session_event_with_fallback(
payload,
headers,
rules.kind,
extractor,
fallback_session_id,
))
} else if rules
.agent_end
.iter()
.any(|name| normalize_name(name) == normalized)
{
NormalizedEvent::AgentEnded(common_session_event_with_fallback(
payload,
headers,
rules.kind,
extractor,
fallback_session_id,
))
} else if rules
.subagent_start
.iter()
.any(|name| normalize_name(name) == normalized)
{
NormalizedEvent::SubagentStarted(common_subagent_event_with_fallback(
payload,
headers,
rules.kind,
extractor,
fallback_session_id,
))
} else if rules
.subagent_end
.iter()
.any(|name| normalize_name(name) == normalized)
{
NormalizedEvent::SubagentEnded(common_subagent_event_with_fallback(
payload,
headers,
rules.kind,
extractor,
fallback_session_id,
))
} else if rules
.tool_start
.iter()
.any(|name| normalize_name(name) == normalized)
{
NormalizedEvent::ToolStarted(common_tool_event_with_fallback(
payload,
headers,
rules.kind,
extractor,
fallback_session_id,
))
} else if rules
.tool_end
.iter()
.any(|name| normalize_name(name) == normalized)
{
NormalizedEvent::ToolEnded(common_tool_event_with_fallback(
payload,
headers,
rules.kind,
extractor,
fallback_session_id,
))
} else {
match normalized.as_str() {
"afteragentresponse" | "agentresponse" | "assistantresponse" | "afteragentthought"
| "prellmcall" | "postllmcall" | "stop" => {
NormalizedEvent::LlmHint(common_llm_hint_event_with_fallback(
payload,
headers,
rules.kind,
extractor,
fallback_session_id,
))
}
"precompact" | "compaction" => {
NormalizedEvent::Compaction(common_session_event_with_fallback(
payload,
headers,
rules.kind,
extractor,
fallback_session_id,
))
}
"notification" => NormalizedEvent::Notification(common_session_event_with_fallback(
payload,
headers,
rules.kind,
extractor,
fallback_session_id,
)),
_ => NormalizedEvent::HookMark(common_session_event_with_fallback(
payload,
headers,
rules.kind,
extractor,
fallback_session_id,
)),
}
}
}
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;