use std::collections::HashMap;
use axum::http::HeaderMap;
use nemo_relay::api::llm::LlmRequest;
use serde_json::{Map, Value, json};
use crate::config::header_string;
pub(crate) use crate::json_path::{string_at_any as json_string_at, value_at_any as json_value_at};
use crate::model::{AgentKind, LlmEvent, NormalizedEvent, SessionEvent, SubagentEvent, ToolEvent};
pub(crate) mod claude_code;
pub(crate) mod codex;
pub(crate) mod hermes;
const REQUEST_AFFINITY_KEY_MIN_CHARS: usize = 24;
const REQUEST_AFFINITY_KEY_MAX_CHARS: usize = 4096;
#[derive(Debug, Clone)]
pub(crate) enum SubagentSessionContext {
Codex(codex::SubagentContext),
Hermes(hermes::SubagentContext),
}
impl SubagentSessionContext {
pub(crate) fn parent_session_id(&self) -> &str {
match self {
Self::Codex(context) => &context.parent_session_id,
Self::Hermes(context) => &context.parent_session_id,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum GatewayRouteKind {
OpenAiResponses,
OpenAiChatCompletions,
OpenAiModels,
AnthropicMessages,
AnthropicCountTokens,
}
impl GatewayRouteKind {
pub(crate) const ALL: [Self; 5] = [
Self::OpenAiResponses,
Self::OpenAiChatCompletions,
Self::OpenAiModels,
Self::AnthropicMessages,
Self::AnthropicCountTokens,
];
pub(crate) const fn name(self) -> &'static str {
match self {
Self::OpenAiResponses => "openai.responses",
Self::OpenAiChatCompletions => "openai.chat_completions",
Self::OpenAiModels => "openai.models",
Self::AnthropicMessages => "anthropic.messages",
Self::AnthropicCountTokens => "anthropic.count_tokens",
}
}
pub(crate) fn from_provider_name(provider: &str) -> Option<Self> {
Self::ALL.into_iter().find(|route| route.name() == provider)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum GatewayManagementPolicy {
Managed,
UnmanagedProbe {
status: &'static str,
source: &'static str,
},
}
impl GatewayManagementPolicy {
pub(crate) fn bypasses_managed_pipeline(self) -> bool {
matches!(self, Self::UnmanagedProbe { .. })
}
pub(crate) fn bypass_correlation(self) -> Option<(&'static str, &'static str)> {
match self {
Self::Managed => None,
Self::UnmanagedProbe { status, source } => Some((status, source)),
}
}
}
pub(crate) trait ProviderRequestExtractor {
fn gateway_session_id(&self, headers: &HeaderMap, body: &Value) -> Option<String>;
fn request_affinity_key(&self, request: &LlmRequest) -> Option<String>;
fn gateway_turn_input(&self, agent_kind: AgentKind, request: &LlmRequest) -> Option<Value>;
}
struct OpenAiResponsesRequestExtractor;
struct OpenAiChatCompletionsRequestExtractor;
struct OpenAiModelsRequestExtractor;
struct AnthropicMessagesRequestExtractor;
struct AnthropicCountTokensRequestExtractor;
static OPENAI_RESPONSES_REQUEST_EXTRACTOR: OpenAiResponsesRequestExtractor =
OpenAiResponsesRequestExtractor;
static OPENAI_CHAT_COMPLETIONS_REQUEST_EXTRACTOR: OpenAiChatCompletionsRequestExtractor =
OpenAiChatCompletionsRequestExtractor;
static OPENAI_MODELS_REQUEST_EXTRACTOR: OpenAiModelsRequestExtractor = OpenAiModelsRequestExtractor;
static ANTHROPIC_MESSAGES_REQUEST_EXTRACTOR: AnthropicMessagesRequestExtractor =
AnthropicMessagesRequestExtractor;
static ANTHROPIC_COUNT_TOKENS_REQUEST_EXTRACTOR: AnthropicCountTokensRequestExtractor =
AnthropicCountTokensRequestExtractor;
impl ProviderRequestExtractor for OpenAiResponsesRequestExtractor {
fn gateway_session_id(&self, headers: &HeaderMap, body: &Value) -> Option<String> {
gateway_header_session_id(headers)
.or_else(|| codex::prompt_cache_session_id(body, GatewayRouteKind::OpenAiResponses))
.or_else(|| openai_body_session_id(body, GatewayRouteKind::OpenAiResponses))
}
fn request_affinity_key(&self, request: &LlmRequest) -> Option<String> {
affinity_key_from_task_text(responses_user_task_text(&request.content)?)
}
fn gateway_turn_input(&self, _agent_kind: AgentKind, _request: &LlmRequest) -> Option<Value> {
None
}
}
impl ProviderRequestExtractor for OpenAiChatCompletionsRequestExtractor {
fn gateway_session_id(&self, headers: &HeaderMap, body: &Value) -> Option<String> {
gateway_header_session_id(headers)
.or_else(|| openai_body_session_id(body, GatewayRouteKind::OpenAiChatCompletions))
}
fn request_affinity_key(&self, request: &LlmRequest) -> Option<String> {
affinity_key_from_task_text(messages_user_task_text(&request.content)?)
}
fn gateway_turn_input(&self, _agent_kind: AgentKind, _request: &LlmRequest) -> Option<Value> {
None
}
}
impl ProviderRequestExtractor for OpenAiModelsRequestExtractor {
fn gateway_session_id(&self, headers: &HeaderMap, _body: &Value) -> Option<String> {
gateway_header_session_id(headers)
}
fn request_affinity_key(&self, _request: &LlmRequest) -> Option<String> {
None
}
fn gateway_turn_input(&self, _agent_kind: AgentKind, _request: &LlmRequest) -> Option<Value> {
None
}
}
impl ProviderRequestExtractor for AnthropicMessagesRequestExtractor {
fn gateway_session_id(&self, headers: &HeaderMap, _body: &Value) -> Option<String> {
gateway_header_session_id(headers)
}
fn request_affinity_key(&self, request: &LlmRequest) -> Option<String> {
affinity_key_from_task_text(messages_user_task_text(&request.content)?)
}
fn gateway_turn_input(&self, agent_kind: AgentKind, request: &LlmRequest) -> Option<Value> {
if agent_kind != AgentKind::ClaudeCode {
return None;
}
messages_user_task_text(&request.content).map(|prompt| json!({ "prompt": prompt }))
}
}
impl ProviderRequestExtractor for AnthropicCountTokensRequestExtractor {
fn gateway_session_id(&self, headers: &HeaderMap, _body: &Value) -> Option<String> {
gateway_header_session_id(headers)
}
fn request_affinity_key(&self, _request: &LlmRequest) -> Option<String> {
None
}
fn gateway_turn_input(&self, _agent_kind: AgentKind, _request: &LlmRequest) -> Option<Value> {
None
}
}
#[derive(Debug, Clone)]
pub(crate) struct SessionAlias {
pub(crate) parent_session_id: String,
pub(crate) subagent_id: String,
metadata: Value,
}
impl SessionAlias {
pub(crate) fn new(parent_session_id: String, subagent_id: String, metadata: Value) -> Self {
Self {
parent_session_id,
subagent_id,
metadata,
}
}
pub(crate) fn metadata(&self) -> Value {
self.metadata.clone()
}
}
#[derive(Debug, Clone)]
pub(crate) struct PendingSubagentStart {
pub(crate) event: SessionEvent,
context: SubagentSessionContext,
}
impl PendingSubagentStart {
pub(crate) fn parent_session_id(&self) -> &str {
self.context.parent_session_id()
}
pub(crate) fn subagent_start_event(&self) -> SubagentEvent {
subagent_start_event(&self.event, &self.context)
}
pub(crate) fn alias_for_child_session(&self, child_session_id: String) -> SessionAlias {
alias_for_child_session(child_session_id, &self.context)
}
}
#[derive(Debug, Default)]
pub(crate) struct SessionAlignmentState {
aliases: HashMap<String, SessionAlias>,
completed_aliases: HashMap<String, SessionAlias>,
pending_subagents: HashMap<String, PendingSubagentStart>,
task_sessions: HashMap<String, HashMap<String, String>>,
}
impl SessionAlignmentState {
pub(crate) fn clear(&mut self) {
self.aliases.clear();
self.completed_aliases.clear();
self.pending_subagents.clear();
self.task_sessions.clear();
}
pub(crate) fn alias_for_session(&self, session_id: &str) -> Option<SessionAlias> {
self.aliases.get(session_id).cloned()
}
#[cfg(test)]
pub(crate) fn has_alias(&self, session_id: &str) -> bool {
self.aliases.contains_key(session_id)
}
#[cfg(test)]
pub(crate) fn has_pending_session(&self, session_id: &str) -> bool {
self.pending_subagents.contains_key(session_id)
}
pub(crate) fn pending_for_session(&mut self, session_id: &str) -> Option<PendingSubagentStart> {
self.pending_subagents.remove(session_id)
}
pub(crate) fn insert_pending(
&mut self,
child_session_id: String,
pending: PendingSubagentStart,
) {
self.pending_subagents.insert(child_session_id, pending);
}
pub(crate) fn remove_pending(&mut self, child_session_id: &str) {
self.pending_subagents.remove(child_session_id);
}
pub(crate) fn insert_alias(&mut self, child_session_id: String, alias: SessionAlias) {
self.aliases.insert(child_session_id, alias);
}
pub(crate) fn route_event(&mut self, event: NormalizedEvent) -> NormalizedEvent {
self.record_task_session(&event);
let event = self.route_task_session_event(event);
let (event, finished_alias) = route_event_through_alias(event, &self.aliases);
let session_id = event.session_id().to_string();
if let Some(child_session_id) = finished_alias.as_ref() {
if let Some(alias) = self.aliases.remove(child_session_id) {
self.completed_aliases
.insert(child_session_id.clone(), alias);
}
self.pending_subagents.remove(child_session_id);
}
if matches!(&event, NormalizedEvent::AgentEnded(_)) {
self.clear_for_ended_agent(&session_id);
}
event
}
pub(crate) fn align_explicit_subagent_end(&mut self, event: &mut NormalizedEvent) {
let NormalizedEvent::SubagentEnded(subagent_event) = event else {
return;
};
let Some(child_session_id) = hermes::child_session_id_for_subagent_event(subagent_event)
else {
return;
};
let Some(alias) = self
.aliases
.get(&child_session_id)
.or_else(|| self.completed_aliases.get(&child_session_id))
.cloned()
else {
return;
};
if subagent_event.session_id != alias.parent_session_id {
return;
}
subagent_event.subagent_id = alias.subagent_id.clone();
subagent_event.metadata = merge_metadata(subagent_event.metadata.clone(), alias.metadata());
self.aliases.remove(&child_session_id);
self.completed_aliases.remove(&child_session_id);
}
pub(crate) fn pending_for_parent(
&mut self,
parent_session_id: &str,
) -> Vec<(String, PendingSubagentStart)> {
let child_session_ids = self
.pending_subagents
.iter()
.filter_map(|(child_session_id, pending)| {
(pending.parent_session_id() == parent_session_id)
.then_some(child_session_id.clone())
})
.collect::<Vec<_>>();
child_session_ids
.into_iter()
.filter_map(|child_session_id| {
self.pending_subagents
.remove(&child_session_id)
.map(|pending| (child_session_id, pending))
})
.collect()
}
pub(crate) fn clear_for_ended_agent(&mut self, session_id: &str) {
self.aliases.retain(|child_session_id, alias| {
child_session_id != session_id && alias.parent_session_id != session_id
});
self.completed_aliases.retain(|child_session_id, alias| {
child_session_id != session_id && alias.parent_session_id != session_id
});
self.pending_subagents.retain(|child_session_id, pending| {
child_session_id != session_id && pending.parent_session_id() != session_id
});
self.task_sessions.remove(session_id);
prune_task_sessions(&mut self.task_sessions, session_id);
}
pub(crate) fn clear_for_ended_subagent(&mut self, parent_session_id: &str, subagent_id: &str) {
self.aliases.retain(|child_session_id, alias| {
child_session_id != subagent_id
&& !(alias.parent_session_id == parent_session_id
&& alias.subagent_id == subagent_id)
});
self.pending_subagents.retain(|child_session_id, pending| {
child_session_id != subagent_id
&& !(pending.parent_session_id() == parent_session_id
&& pending.event.session_id == subagent_id)
});
self.task_sessions
.retain(|session_id, _| session_id != subagent_id);
prune_task_sessions(&mut self.task_sessions, subagent_id);
}
fn record_task_session(&mut self, event: &NormalizedEvent) {
if normalized_event_agent_kind(event) != AgentKind::Hermes {
return;
}
let Some(task_id) = event_task_id(event) else {
return;
};
let session_id = event.session_id();
if session_id == task_id {
return;
}
self.task_sessions
.entry(session_id.to_string())
.or_default()
.insert(task_id, session_id.to_string());
}
fn route_task_session_event(&self, event: NormalizedEvent) -> NormalizedEvent {
let should_route = matches!(
event,
NormalizedEvent::ToolStarted(_) | NormalizedEvent::ToolEnded(_)
) && normalized_event_agent_kind(&event) == AgentKind::Hermes;
if !should_route {
return event;
}
let task_id = event_task_id(&event).unwrap_or_else(|| event.session_id().to_string());
let session_scope = event_task_session_scope(&event);
let Some(session_id) = self.session_for_task(&task_id, session_scope.as_deref()) else {
return event;
};
route_task_session_event(event, task_id, session_id)
}
fn session_for_task(&self, task_id: &str, session_scope: Option<&str>) -> Option<String> {
if let Some(session_scope) = session_scope {
return self
.task_sessions
.get(session_scope)
.and_then(|tasks| tasks.get(task_id))
.cloned();
}
let mut matches = self
.task_sessions
.values()
.filter_map(|tasks| tasks.get(task_id).cloned());
let session_id = matches.next()?;
matches.next().is_none().then_some(session_id)
}
}
fn prune_task_sessions(
task_sessions: &mut HashMap<String, HashMap<String, String>>,
session_id: &str,
) {
task_sessions.values_mut().for_each(|tasks| {
tasks.retain(|task_id, mapped_session_id| {
task_id != session_id && mapped_session_id != session_id
});
});
task_sessions.retain(|_, tasks| !tasks.is_empty());
}
pub(crate) fn gateway_session_id(
headers: &HeaderMap,
body: &Value,
route: GatewayRouteKind,
) -> Option<String> {
provider_request_extractor(route).gateway_session_id(headers, body)
}
fn provider_request_extractor(route: GatewayRouteKind) -> &'static dyn ProviderRequestExtractor {
match route {
GatewayRouteKind::OpenAiResponses => &OPENAI_RESPONSES_REQUEST_EXTRACTOR,
GatewayRouteKind::OpenAiChatCompletions => &OPENAI_CHAT_COMPLETIONS_REQUEST_EXTRACTOR,
GatewayRouteKind::OpenAiModels => &OPENAI_MODELS_REQUEST_EXTRACTOR,
GatewayRouteKind::AnthropicMessages => &ANTHROPIC_MESSAGES_REQUEST_EXTRACTOR,
GatewayRouteKind::AnthropicCountTokens => &ANTHROPIC_COUNT_TOKENS_REQUEST_EXTRACTOR,
}
}
fn provider_request_extractor_for_name(
provider: &str,
) -> Option<&'static dyn ProviderRequestExtractor> {
GatewayRouteKind::from_provider_name(provider).map(provider_request_extractor)
}
fn gateway_header_session_id(headers: &HeaderMap) -> Option<String> {
header_string(headers, "x-nemo-relay-session-id")
.or_else(|| claude_code::session_id_from_headers(headers))
}
fn openai_body_session_id(body: &Value, route: GatewayRouteKind) -> Option<String> {
if !matches!(
route,
GatewayRouteKind::OpenAiChatCompletions | GatewayRouteKind::OpenAiResponses
) {
return None;
}
body.get("session_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|session_id| !session_id.is_empty())
.map(ToOwned::to_owned)
}
pub(crate) fn gateway_upstream_url_override(
headers: &HeaderMap,
route: GatewayRouteKind,
path_and_query: &str,
has_openai_replacement_key: bool,
) -> Option<String> {
codex::chatgpt_upstream_url_if_needed(
headers,
route,
path_and_query,
has_openai_replacement_key,
)
}
pub(crate) fn gateway_forward_headers(
headers: &HeaderMap,
route: GatewayRouteKind,
has_openai_replacement_key: bool,
) -> HeaderMap {
codex::strip_chatgpt_oauth_for_openai_route(headers, route, has_openai_replacement_key)
}
pub(crate) fn gateway_subagent_id(headers: &HeaderMap) -> Option<String> {
header_string(headers, "x-nemo-relay-subagent-id")
}
pub(crate) fn gateway_identifier(
headers: &HeaderMap,
body: &Value,
header_name: &'static str,
body_paths: &[&[&str]],
) -> Option<String> {
header_string(headers, header_name).or_else(|| json_string_at(body, body_paths))
}
pub(crate) fn agent_kind_for_gateway_provider(provider: &str) -> AgentKind {
if claude_code::owns_gateway_provider(provider) {
AgentKind::ClaudeCode
} else if codex::owns_gateway_provider(provider) {
AgentKind::Codex
} else {
AgentKind::Gateway
}
}
pub(crate) fn gateway_management_policy(
agent_kind: AgentKind,
provider: &str,
model_name: Option<&str>,
request: &LlmRequest,
) -> GatewayManagementPolicy {
if agent_kind == AgentKind::ClaudeCode
&& claude_code::is_startup_probe(provider, model_name, request)
{
GatewayManagementPolicy::UnmanagedProbe {
status: "pre_turn_probe",
source: "claude_startup_probe",
}
} else {
GatewayManagementPolicy::Managed
}
}
pub(crate) fn should_emit_session_agent_scope(agent_kind: AgentKind) -> bool {
!matches!(agent_kind, AgentKind::ClaudeCode | AgentKind::Codex)
}
pub(crate) async fn subagent_session_context(
event: &SessionEvent,
) -> Option<SubagentSessionContext> {
codex::subagent_context(event)
.await
.map(SubagentSessionContext::Codex)
.or_else(|| hermes::subagent_context(event).map(SubagentSessionContext::Hermes))
}
pub(crate) async fn pending_subagent_start(
event: &mut NormalizedEvent,
) -> Option<(String, PendingSubagentStart)> {
let NormalizedEvent::AgentStarted(session_event) = event else {
return None;
};
let context = subagent_session_context(session_event).await?;
let child_session_id = session_event.session_id.clone();
if context.parent_session_id() == child_session_id {
return None;
}
session_event.metadata =
augment_subagent_session_metadata(session_event.metadata.clone(), &context);
Some((
child_session_id,
PendingSubagentStart {
event: session_event.clone(),
context,
},
))
}
pub(crate) fn augment_subagent_session_metadata(
metadata: Value,
context: &SubagentSessionContext,
) -> Value {
match context {
SubagentSessionContext::Codex(context) => {
codex::augment_subagent_metadata(metadata, context)
}
SubagentSessionContext::Hermes(context) => {
hermes::augment_subagent_metadata(metadata, context)
}
}
}
pub(crate) fn subagent_start_event(
event: &SessionEvent,
context: &SubagentSessionContext,
) -> SubagentEvent {
match context {
SubagentSessionContext::Codex(context) => codex::subagent_start_event(event, context),
SubagentSessionContext::Hermes(context) => hermes::subagent_start_event(event, context),
}
}
pub(crate) fn alias_for_child_session(
child_session_id: String,
context: &SubagentSessionContext,
) -> SessionAlias {
match context {
SubagentSessionContext::Codex(context) => {
codex::alias_for_child_session(child_session_id, context)
}
SubagentSessionContext::Hermes(context) => {
hermes::alias_for_child_session(child_session_id, context)
}
}
}
pub(crate) fn explicit_subagent_alias(
event: &mut NormalizedEvent,
) -> Option<(String, SessionAlias)> {
let NormalizedEvent::SubagentStarted(subagent_event) = event else {
return None;
};
let explicit = hermes::explicit_subagent_alias(subagent_event)?;
subagent_event.metadata =
merge_metadata(subagent_event.metadata.clone(), explicit.scope_metadata);
Some((explicit.child_session_id, explicit.alias))
}
pub(crate) fn llm_owner_metadata(scope_metadata: Option<&Value>) -> Value {
merge_metadata(
codex::llm_owner_metadata(scope_metadata),
hermes::llm_owner_metadata(scope_metadata),
)
}
pub(crate) fn request_affinity_key(provider: &str, request: &LlmRequest) -> Option<String> {
provider_request_extractor_for_name(provider)?.request_affinity_key(request)
}
pub(crate) fn gateway_turn_input(
agent_kind: AgentKind,
provider: &str,
request: &LlmRequest,
) -> Option<Value> {
provider_request_extractor_for_name(provider)?.gateway_turn_input(agent_kind, request)
}
pub(crate) fn completed_subagent_from_tool(event: &ToolEvent) -> Option<String> {
claude_code::completed_subagent_from_agent_tool(event)
}
pub(crate) fn aliased_turn_subagent_id(event: &SessionEvent) -> Option<String> {
json_string_at(
&event.metadata,
&[
&["hermes_child_subagent_id"][..],
&["subagent_id"][..],
&["codex_subagent_session_id"][..],
&["subagent_session_id"][..],
],
)
}
pub(crate) fn route_event_through_alias(
event: NormalizedEvent,
aliases: &HashMap<String, SessionAlias>,
) -> (NormalizedEvent, Option<String>) {
let child_session_id = event.session_id().to_string();
let Some(alias) = aliases.get(&child_session_id).cloned() else {
return (event, None);
};
let metadata = alias.metadata();
match event {
NormalizedEvent::AgentStarted(event) => (
NormalizedEvent::SubagentStarted(SubagentEvent {
session_id: alias.parent_session_id,
agent_kind: event.agent_kind,
event_name: event.event_name,
subagent_id: alias.subagent_id,
payload: event.payload,
metadata: merge_metadata(event.metadata, metadata),
}),
None,
),
NormalizedEvent::AgentEnded(event) => (
NormalizedEvent::SubagentEnded(SubagentEvent {
session_id: alias.parent_session_id,
agent_kind: event.agent_kind,
event_name: event.event_name,
subagent_id: alias.subagent_id,
payload: event.payload,
metadata: merge_metadata(event.metadata, metadata),
}),
Some(child_session_id),
),
NormalizedEvent::TurnEnded(mut event) => {
route_session_event(&mut event, &alias, metadata);
(NormalizedEvent::TurnEnded(event), Some(child_session_id))
}
NormalizedEvent::PromptSubmitted(mut event) => {
route_session_event(&mut event, &alias, metadata);
(NormalizedEvent::PromptSubmitted(event), None)
}
NormalizedEvent::Compaction(mut event) => {
route_session_event(&mut event, &alias, metadata);
(NormalizedEvent::Compaction(event), None)
}
NormalizedEvent::Notification(mut event) => {
route_session_event(&mut event, &alias, metadata);
(NormalizedEvent::Notification(event), None)
}
NormalizedEvent::HookMark(mut event) => {
route_session_event(&mut event, &alias, metadata);
(NormalizedEvent::HookMark(event), None)
}
NormalizedEvent::SubagentStarted(mut event) => {
route_subagent_event(&mut event, &alias, metadata);
(NormalizedEvent::SubagentStarted(event), None)
}
NormalizedEvent::SubagentEnded(mut event) => {
route_subagent_event(&mut event, &alias, metadata);
(NormalizedEvent::SubagentEnded(event), None)
}
NormalizedEvent::LlmHint(mut event) => {
event.session_id = alias.parent_session_id;
event.subagent_id = Some(alias.subagent_id);
event.metadata = merge_metadata(event.metadata, metadata);
(NormalizedEvent::LlmHint(event), None)
}
NormalizedEvent::LlmStarted(mut event) => {
route_llm_event(&mut event, &alias, metadata);
(NormalizedEvent::LlmStarted(event), None)
}
NormalizedEvent::LlmEnded(mut event) => {
route_llm_event(&mut event, &alias, metadata);
(NormalizedEvent::LlmEnded(event), None)
}
NormalizedEvent::ToolStarted(mut event) => {
route_tool_event(&mut event, &alias, metadata);
(NormalizedEvent::ToolStarted(event), None)
}
NormalizedEvent::ToolEnded(mut event) => {
route_tool_event(&mut event, &alias, metadata);
(NormalizedEvent::ToolEnded(event), None)
}
}
}
fn route_session_event(event: &mut SessionEvent, alias: &SessionAlias, metadata: Value) {
event.session_id = alias.parent_session_id.clone();
event.metadata = merge_metadata(event.metadata.clone(), metadata);
}
fn route_subagent_event(event: &mut SubagentEvent, alias: &SessionAlias, metadata: Value) {
event.session_id = alias.parent_session_id.clone();
event.metadata = merge_metadata(event.metadata.clone(), metadata);
}
fn route_llm_event(event: &mut LlmEvent, alias: &SessionAlias, metadata: Value) {
event.session_id = alias.parent_session_id.clone();
event.metadata = merge_metadata(
event.metadata.clone(),
merge_metadata(
metadata,
json!({
"llm_correlation_status": "session_alias",
"llm_correlation_source": "session_alias",
"llm_correlation_subagent_id": alias.subagent_id.clone(),
}),
),
);
}
fn route_tool_event(event: &mut ToolEvent, alias: &SessionAlias, metadata: Value) {
event.session_id = alias.parent_session_id.clone();
event.subagent_id = Some(alias.subagent_id.clone());
event.metadata = merge_metadata(event.metadata.clone(), metadata);
}
fn route_task_session_event(
event: NormalizedEvent,
task_id: String,
session_id: String,
) -> NormalizedEvent {
let metadata = json!({
"session_correlation_status": "task_session_alias",
"session_correlation_source": "task_id",
"hermes_task_id": task_id,
"hermes_session_id": session_id,
});
match event {
NormalizedEvent::ToolStarted(mut event) => {
event.session_id = session_id;
event.metadata = merge_metadata(event.metadata, metadata);
NormalizedEvent::ToolStarted(event)
}
NormalizedEvent::ToolEnded(mut event) => {
event.session_id = session_id;
event.metadata = merge_metadata(event.metadata, metadata);
NormalizedEvent::ToolEnded(event)
}
event => event,
}
}
fn normalized_event_agent_kind(event: &NormalizedEvent) -> AgentKind {
match event {
NormalizedEvent::AgentStarted(event)
| NormalizedEvent::AgentEnded(event)
| NormalizedEvent::TurnEnded(event)
| NormalizedEvent::PromptSubmitted(event)
| NormalizedEvent::Compaction(event)
| NormalizedEvent::Notification(event)
| NormalizedEvent::HookMark(event) => event.agent_kind,
NormalizedEvent::SubagentStarted(event) | NormalizedEvent::SubagentEnded(event) => {
event.agent_kind
}
NormalizedEvent::LlmHint(event) => event.agent_kind,
NormalizedEvent::LlmStarted(event) | NormalizedEvent::LlmEnded(event) => event.agent_kind,
NormalizedEvent::ToolStarted(event) | NormalizedEvent::ToolEnded(event) => event.agent_kind,
}
}
fn event_task_id(event: &NormalizedEvent) -> Option<String> {
match event {
NormalizedEvent::AgentStarted(event)
| NormalizedEvent::AgentEnded(event)
| NormalizedEvent::TurnEnded(event)
| NormalizedEvent::PromptSubmitted(event)
| NormalizedEvent::Compaction(event)
| NormalizedEvent::Notification(event)
| NormalizedEvent::HookMark(event) => {
task_id_from_payload_and_metadata(&event.payload, &event.metadata)
}
NormalizedEvent::SubagentStarted(event) | NormalizedEvent::SubagentEnded(event) => {
task_id_from_payload_and_metadata(&event.payload, &event.metadata)
}
NormalizedEvent::LlmHint(event) => {
task_id_from_payload_and_metadata(&event.payload, &event.metadata)
}
NormalizedEvent::LlmStarted(event) | NormalizedEvent::LlmEnded(event) => {
task_id_from_llm_event(event)
}
NormalizedEvent::ToolStarted(event) | NormalizedEvent::ToolEnded(event) => {
task_id_from_payload_and_metadata(&event.payload, &event.metadata)
}
}
}
fn event_task_session_scope(event: &NormalizedEvent) -> Option<String> {
match event {
NormalizedEvent::AgentStarted(event)
| NormalizedEvent::AgentEnded(event)
| NormalizedEvent::TurnEnded(event)
| NormalizedEvent::PromptSubmitted(event)
| NormalizedEvent::Compaction(event)
| NormalizedEvent::Notification(event)
| NormalizedEvent::HookMark(event) => {
session_scope_from_payload_and_metadata(&event.payload, &event.metadata)
}
NormalizedEvent::SubagentStarted(event) | NormalizedEvent::SubagentEnded(event) => {
session_scope_from_payload_and_metadata(&event.payload, &event.metadata)
}
NormalizedEvent::LlmHint(event) => {
session_scope_from_payload_and_metadata(&event.payload, &event.metadata)
}
NormalizedEvent::LlmStarted(event) | NormalizedEvent::LlmEnded(event) => {
session_scope_from_llm_event(event)
}
NormalizedEvent::ToolStarted(event) | NormalizedEvent::ToolEnded(event) => {
session_scope_from_payload_and_metadata(&event.payload, &event.metadata)
}
}
}
fn task_id_from_llm_event(event: &LlmEvent) -> Option<String> {
task_id_from_payload_and_metadata(&event.request, &event.metadata)
.or_else(|| task_id_from_payload_and_metadata(&event.response, &event.metadata))
}
fn task_id_from_payload_and_metadata(payload: &Value, metadata: &Value) -> Option<String> {
json_string_at(payload, TASK_ID_PATHS).or_else(|| json_string_at(metadata, TASK_ID_PATHS))
}
fn session_scope_from_llm_event(event: &LlmEvent) -> Option<String> {
session_scope_from_payload_and_metadata(&event.request, &event.metadata)
.or_else(|| session_scope_from_payload_and_metadata(&event.response, &event.metadata))
}
fn session_scope_from_payload_and_metadata(payload: &Value, metadata: &Value) -> Option<String> {
json_string_at(payload, TASK_SESSION_SCOPE_PATHS)
.or_else(|| json_string_at(metadata, TASK_SESSION_SCOPE_PATHS))
}
const TASK_ID_PATHS: &[&[&str]] = &[
&["task_id"],
&["taskId"],
&["extra", "task_id"],
&["extra", "taskId"],
];
const TASK_SESSION_SCOPE_PATHS: &[&[&str]] = &[
&["session_id"],
&["sessionId"],
&["session", "id"],
&["conversation_id"],
&["conversationId"],
&["parent_session_id"],
&["parentSessionId"],
&["extra", "session_id"],
&["extra", "sessionId"],
&["extra", "parent_session_id"],
&["extra", "parentSessionId"],
];
fn messages_user_task_text(payload: &Value) -> Option<String> {
payload
.get("messages")
.and_then(Value::as_array)
.and_then(|messages| messages.iter().rev().find_map(user_message_task_text))
}
fn responses_user_task_text(payload: &Value) -> Option<String> {
payload
.get("input")
.and_then(responses_input_task_text)
.or_else(|| payload.get("prompt").and_then(prompt_task_text))
}
fn affinity_key_from_task_text(task_text: String) -> Option<String> {
let normalized = normalize_affinity_text(&task_text);
(normalized.chars().count() >= REQUEST_AFFINITY_KEY_MIN_CHARS)
.then(|| truncate_affinity_text(&normalized, REQUEST_AFFINITY_KEY_MAX_CHARS))
}
fn user_message_task_text(message: &Value) -> Option<String> {
if message.get("role").and_then(Value::as_str) != Some("user") {
return None;
}
content_task_text(message.get("content")?)
}
fn responses_input_task_text(input: &Value) -> Option<String> {
match input {
Value::String(text) => affinity_candidate_text(text),
Value::Array(items) => items.iter().rev().find_map(user_message_task_text),
_ => None,
}
}
fn prompt_task_text(prompt: &Value) -> Option<String> {
prompt.as_str().and_then(affinity_candidate_text)
}
fn content_task_text(content: &Value) -> Option<String> {
match content {
Value::String(text) => affinity_candidate_text(text),
Value::Array(blocks) => blocks.iter().rev().find_map(content_block_task_text),
_ => None,
}
}
fn content_block_task_text(block: &Value) -> Option<String> {
if let Some(block_type) = block.get("type").and_then(Value::as_str)
&& !matches!(block_type, "text" | "input_text")
{
return None;
}
block
.get("text")
.and_then(Value::as_str)
.and_then(affinity_candidate_text)
}
fn affinity_candidate_text(text: &str) -> Option<String> {
let cleaned = text.trim();
if cleaned.is_empty() || looks_like_json_payload(cleaned) {
return None;
}
Some(cleaned.to_string())
}
fn looks_like_json_payload(text: &str) -> bool {
let trimmed = text.trim_start();
if !matches!(trimmed.chars().next(), Some('{' | '[')) {
return false;
}
matches!(
serde_json::from_str::<Value>(trimmed),
Ok(Value::Object(_) | Value::Array(_))
)
}
fn normalize_affinity_text(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn truncate_affinity_text(text: &str, max_chars: usize) -> String {
text.chars().take(max_chars).collect()
}
pub(crate) fn insert_optional(object: &mut Map<String, Value>, key: &str, value: Option<&str>) {
if let Some(value) = value {
object.insert(key.to_string(), json!(value));
}
}
pub(crate) fn merge_metadata(left: Value, right: Value) -> Value {
match (left, right) {
(Value::Object(mut left), Value::Object(right)) => {
for (key, value) in right {
if !value.is_null() {
left.insert(key, value);
}
}
Value::Object(left)
}
(Value::Null, right) => right,
(left, Value::Null) => left,
(left, right) => {
let mut object = Map::new();
object.insert("metadata".into(), left);
object.insert("extra_metadata".into(), right);
Value::Object(object)
}
}
}
#[cfg(test)]
#[path = "../../tests/coverage/alignment_tests.rs"]
mod tests;