Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Fixed

- Create Hermes `ENTRY` spans for platform-less `AIAgent` calls by mirroring
Hermes session source resolution: `platform`, `HERMES_SESSION_SOURCE`, then
`cli`. This changes no-platform runs from `AGENT`-only to `ENTRY` -> `AGENT`
while leaving explicit CLI, IM, TUI, and API Server platform paths unchanged;
disable the Hermes instrumentation if a process must keep no-platform top
level agent calls as `AGENT`-only.

## Version 0.6.0 (2026-06-03)

There are no changelog entries for this release.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@ export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=NO_CONTENT
## Supported Signals

- **AGENT**: top-level Hermes agent invocation
- **ENTRY**: AI application entry spans when Hermes `AIAgent.platform` identifies an entrypoint such as CLI, TUI, API Server, or gateway adapters
- **ENTRY**: AI application entry spans when Hermes resolves an entry source
from `AIAgent.platform`, `HERMES_SESSION_SOURCE`, or the default `cli`
source used by platform-less `AIAgent` instances. Every top-level
instrumented `AIAgent.run_conversation` now emits `ENTRY` -> `AGENT`.
- **STEP**: Hermes ReAct step lifecycle
- **LLM**: synchronous and streaming model calls
- **TOOL**: Hermes tool execution, including tool call id, arguments, and result
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import contextvars
import importlib
import json
import os
from types import SimpleNamespace
from typing import Any

Expand Down Expand Up @@ -59,6 +60,7 @@
)

_HERMES_AGENT_SYSTEM = "hermes"
_DEFAULT_ENTRY_PLATFORM = "cli"


def obj_get(value: Any, field: str, default: Any = None) -> Any:
Expand All @@ -72,9 +74,16 @@ def _normalize_platform(value: Any) -> str:
return str(platform or "").strip().lower()


def _entry_platform(instance: Any) -> str:
def resolve_entry_platform(instance: Any) -> str:
"""Resolve the Hermes entry source using AIAgent's session source order."""

platform = _normalize_platform(getattr(instance, "platform", None))
return platform
if platform:
return platform
platform = _normalize_platform(os.environ.get("HERMES_SESSION_SOURCE"))
if platform:
return platform
return _DEFAULT_ENTRY_PLATFORM


def to_int(value: Any) -> int:
Expand Down Expand Up @@ -605,10 +614,6 @@ def create_entry_invocation(
return invocation


def should_create_entry_for_agent(instance: Any) -> bool:
return bool(_entry_platform(instance))


def create_llm_invocation(instance: Any, api_kwargs: Any) -> LLMInvocation:
if not isinstance(api_kwargs, dict):
api_kwargs = {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
provider_name,
push_state,
reset_state,
should_create_entry_for_agent,
resolve_entry_platform,
start_step,
state,
step_finish_reason,
Expand Down Expand Up @@ -148,8 +148,13 @@ def __call__(self, wrapped, instance, args, kwargs):
state_token = push_state(instance)
current_state = state(instance)
entry_invocation = None
# EntryInvocation has no source field yet; resolve the Hermes source
# here so entry creation follows Hermes's own session source default.
# TODO(loongsuite-hermes): pass entry_platform into EntryInvocation if
# opentelemetry-util-genai adds a session source field.
entry_platform = resolve_entry_platform(instance)
if (
should_create_entry_for_agent(instance)
entry_platform
and not _current_span_is_genai_operation()
and not _ACTIVE_TOOL_NAMES.get()
):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -726,12 +726,104 @@ def test_cli_platform_agent_creates_entry_parent_span(
_assert_parent(agent_span, entry_span)


def test_agent_without_platform_does_not_create_entry_span(
def test_tui_platform_agent_creates_entry_parent_span(
instrumentation_module,
tracer_provider,
meter_provider,
span_exporter,
):
runtime = _runtime(instrumentation_module, tracer_provider, meter_provider)
agent = _FakeAgent(session_id="tui-session", platform="tui")

runtime.run_wrapper(
lambda user_message: {"final_response": "完成"},
agent,
("请回复:完成",),
{},
)

entry_span = _spans_by_kind(span_exporter, "ENTRY")[0]
agent_span = _spans_by_kind(span_exporter, "AGENT")[0]
_assert_standard_entry_span(
entry_span,
session_id="tui-session",
input_text="请回复:完成",
output_text="完成",
)
_assert_parent(agent_span, entry_span)


def test_entry_platform_uses_env_session_source_when_agent_platform_missing(
monkeypatch,
):
helpers = importlib.import_module(
"opentelemetry.instrumentation.hermes_agent.helpers"
)
monkeypatch.setenv("HERMES_SESSION_SOURCE", " Web ")
agent = _FakeAgent(session_id="env-session")

assert helpers.resolve_entry_platform(agent) == "web"


def test_entry_platform_prefers_agent_platform_over_env_session_source(
monkeypatch,
):
helpers = importlib.import_module(
"opentelemetry.instrumentation.hermes_agent.helpers"
)
monkeypatch.setenv("HERMES_SESSION_SOURCE", "web")
agent = _FakeAgent(session_id="dingtalk-session", platform="dingtalk")

assert helpers.resolve_entry_platform(agent) == "dingtalk"


def test_entry_platform_empty_env_uses_cli_default(monkeypatch):
helpers = importlib.import_module(
"opentelemetry.instrumentation.hermes_agent.helpers"
)
monkeypatch.setenv("HERMES_SESSION_SOURCE", " ")
agent = _FakeAgent(session_id="default-session")

assert helpers.resolve_entry_platform(agent) == "cli"


def test_agent_without_platform_uses_env_session_source_for_entry(
instrumentation_module,
tracer_provider,
meter_provider,
span_exporter,
monkeypatch,
):
monkeypatch.setenv("HERMES_SESSION_SOURCE", "web")
runtime = _runtime(instrumentation_module, tracer_provider, meter_provider)
agent = _FakeAgent(session_id="env-entry-session")

runtime.run_wrapper(
lambda user_message: {"final_response": "完成"},
agent,
("请回复:完成",),
{},
)

entry_span = _spans_by_kind(span_exporter, "ENTRY")[0]
agent_span = _spans_by_kind(span_exporter, "AGENT")[0]
_assert_standard_entry_span(
entry_span,
session_id="env-entry-session",
input_text="请回复:完成",
output_text="完成",
)
_assert_parent(agent_span, entry_span)


def test_agent_without_platform_or_env_uses_cli_default_entry_source(
instrumentation_module,
tracer_provider,
meter_provider,
span_exporter,
monkeypatch,
):
monkeypatch.delenv("HERMES_SESSION_SOURCE", raising=False)
runtime = _runtime(instrumentation_module, tracer_provider, meter_provider)
agent = _FakeAgent(session_id="library-session")

Expand All @@ -742,8 +834,15 @@ def test_agent_without_platform_does_not_create_entry_span(
{},
)

assert _spans_by_kind(span_exporter, "ENTRY") == []
assert len(_spans_by_kind(span_exporter, "AGENT")) == 1
entry_span = _spans_by_kind(span_exporter, "ENTRY")[0]
agent_span = _spans_by_kind(span_exporter, "AGENT")[0]
_assert_standard_entry_span(
entry_span,
session_id="library-session",
input_text="请回复:完成",
output_text="完成",
)
_assert_parent(agent_span, entry_span)


def test_agent_span_does_not_backfill_agent_id_from_session_id(
Expand Down
Loading