Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions crates/cli/src/adapters/hermes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ pub(crate) fn adapt(payload: Value, headers: &HeaderMap) -> AdapterOutcome {
response: json!({}),
};
}
if normalized == "pretoolcall" && !hermes_pre_tool_call_is_correlatable(&payload, headers) {
return AdapterOutcome {
events: Vec::new(),
response: json!({}),
};
}

// `on_session_end` is a Hermes per-turn boundary, not user-visible trajectory content.
// Emitting it as both HookMark and TurnEnded polluted ATIF with system rows whose only purpose
Expand Down Expand Up @@ -221,6 +227,42 @@ fn hermes_payload_exact(payload: &Value, event_name: &str) -> bool {
}
}

fn hermes_pre_tool_call_is_correlatable(payload: &Value, headers: &HeaderMap) -> bool {
// Public Hermes releases can emit `pre_tool_call` with only a turn/task id. Treating that
// `task_id` as a session opens a synthetic session that is later closed as `gateway_shutdown`.
// Keep pre-tool spans only when they can be routed to a real session and paired with a stable
// tool call id. The matching `post_tool_call` still records the tool result.
has_explicit_hermes_session_id(payload, headers) && has_explicit_hermes_tool_call_id(payload)
}

fn has_explicit_hermes_session_id(payload: &Value, headers: &HeaderMap) -> bool {
header_has_value(headers, "x-nemo-relay-session-id")
|| header_has_value(headers, "x-claude-code-session-id")
|| hermes_string_at(payload, "session_id").is_some()
|| hermes_string_at(payload, "sessionId").is_some()
|| value_at(payload, &["session", "id"]).is_some()
|| hermes_string_at(payload, "conversation_id").is_some()
|| hermes_string_at(payload, "conversationId").is_some()
|| hermes_string_at(payload, "parent_session_id").is_some()
}

fn has_explicit_hermes_tool_call_id(payload: &Value) -> bool {
hermes_string_at(payload, "tool_call_id").is_some()
|| hermes_string_at(payload, "toolCallId").is_some()
|| hermes_string_at(payload, "tool_use_id").is_some()
|| hermes_string_at(payload, "call_id").is_some()
|| value_at(payload, &["tool", "id"]).is_some()
|| value_at(payload, &["tool_input", "id"]).is_some()
|| hermes_string_at(payload, "id").is_some()
}

fn header_has_value(headers: &HeaderMap, name: &str) -> bool {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| !value.trim().is_empty())
}

fn hermes_exact_request(payload: &Value) -> Option<Value> {
let request = hermes_value_at(payload, "request")?;
// Hermes bounds hook payload size before invoking plugins. A truncated payload is intentionally
Expand Down
21 changes: 19 additions & 2 deletions crates/cli/tests/coverage/adapters_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,9 @@ fn maps_hermes_shell_hook_tool_payload() {
"hook_event_name": "pre_tool_call",
"tool_name": "terminal",
"tool_input": { "command": "pwd" },
"session_id": "",
"session_id": "hermes-session",
"extra": {
"task_id": "hermes-session",
"task_id": "task-1",
"tool_call_id": "tool-1"
}
}),
Expand All @@ -308,6 +308,23 @@ fn maps_hermes_shell_hook_tool_payload() {
assert_eq!(outcome.response, json!({}));
}

#[test]
fn drops_uncorrelatable_hermes_pre_tool_call() {
let headers = HeaderMap::new();
let outcome = hermes::adapt(
json!({
"hook_event_name": "pre_tool_call",
"task_id": "task-1",
"tool_name": "terminal",
"tool_input": { "command": "pwd" }
}),
&headers,
);

assert!(outcome.events.is_empty());
assert_eq!(outcome.response, json!({}));
}

#[test]
fn maps_hermes_subagent_child_identifiers() {
let headers = HeaderMap::new();
Expand Down
47 changes: 47 additions & 0 deletions crates/cli/tests/coverage/session_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1451,6 +1451,53 @@ async fn writes_hermes_api_hook_usage_to_atif_metrics() {
assert_eq!(atif["final_metrics"]["total_cached_tokens"], json!(3));
}

#[tokio::test]
async fn hermes_uncorrelatable_pre_tool_call_does_not_create_shutdown_trajectory() {
let _guard = OBSERVABILITY_PLUGIN_TEST_LOCK.lock().await;
let temp = tempfile::tempdir().unwrap();
let atif_dir = temp.path().join("atif");
install_test_atif_plugin(&atif_dir).await;
let config = session_test_config();
let manager = SessionManager::new(config);
let headers = HeaderMap::new();

for payload in [
json!({
"hook_event_name": "on_session_start",
"session_id": "hermes-main"
}),
json!({
"hook_event_name": "pre_tool_call",
"task_id": "task-1",
"tool_name": "terminal",
"tool_input": { "command": "pwd" }
}),
json!({
"hook_event_name": "on_session_finalize",
"session_id": "hermes-main"
}),
] {
let outcome = crate::adapters::hermes::adapt(payload, &headers);
manager
.apply_events(&headers, outcome.events)
.await
.unwrap();
}

manager.close_all("gateway_shutdown").await.unwrap();
clear_plugin_configuration().unwrap();

let trajectories: Vec<Value> = std::fs::read_dir(&atif_dir)
.unwrap()
.filter_map(Result::ok)
.map(|entry| serde_json::from_slice(&std::fs::read(entry.path()).unwrap()).unwrap())
.collect();
let serialized = serde_json::to_string(&trajectories).unwrap();
assert!(serialized.contains("hermes-main"));
assert!(!serialized.contains("task-1"));
assert!(!serialized.contains("gateway_shutdown"));
}

#[tokio::test]
async fn hermes_turn_end_snapshots_atif_without_boundary_system_step() {
let _guard = OBSERVABILITY_PLUGIN_TEST_LOCK.lock().await;
Expand Down
Loading