diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/CHANGELOG.md b/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/CHANGELOG.md index addb2c2c5..478b3d4d7 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/CHANGELOG.md +++ b/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/CHANGELOG.md @@ -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. diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/README.md b/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/README.md index fe3091973..2a443bf4c 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/README.md +++ b/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/README.md @@ -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 diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/src/opentelemetry/instrumentation/hermes_agent/helpers.py b/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/src/opentelemetry/instrumentation/hermes_agent/helpers.py index 80459e88f..2a150ef33 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/src/opentelemetry/instrumentation/hermes_agent/helpers.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/src/opentelemetry/instrumentation/hermes_agent/helpers.py @@ -19,6 +19,7 @@ import contextvars import importlib import json +import os from types import SimpleNamespace from typing import Any @@ -59,6 +60,7 @@ ) _HERMES_AGENT_SYSTEM = "hermes" +_DEFAULT_ENTRY_PLATFORM = "cli" def obj_get(value: Any, field: str, default: Any = None) -> Any: @@ -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: @@ -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 = {} diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/src/opentelemetry/instrumentation/hermes_agent/wrappers.py b/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/src/opentelemetry/instrumentation/hermes_agent/wrappers.py index b857d799c..73427e2c3 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/src/opentelemetry/instrumentation/hermes_agent/wrappers.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/src/opentelemetry/instrumentation/hermes_agent/wrappers.py @@ -36,7 +36,7 @@ provider_name, push_state, reset_state, - should_create_entry_for_agent, + resolve_entry_platform, start_step, state, step_finish_reason, @@ -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() ): diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/tests/test_telemetry_spec.py b/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/tests/test_telemetry_spec.py index e6bca6b0d..82e246857 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/tests/test_telemetry_spec.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-hermes-agent/tests/test_telemetry_spec.py @@ -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") @@ -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(