feat(observability): observer-grade telemetry hooks + NeMo-Relay plugin
Adds backend-neutral observer hooks for plugins: session, turn, API request, tool, approval, and subagent lifecycle events with stable correlation IDs (session_id, task_id, turn_id, api_request_id, tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with api_request_error and subagent_start. Hot path is zero-cost when no plugin subscribes: has_hook()/presence checks gate all payload construction, request payloads are returned by reference when no middleware rewrites, and the sanitized response payload no longer embeds raw response objects. Bundles the optional NeMo-Relay observability plugin (plugins/observability/nemo_relay) as an in-repo consumer of the new hooks, peer to the existing langfuse plugin. Fails open when the optional nemo-relay package is not installed. Authored-by: Bryan Bednarski <bbednarski@nvidia.com> Salvaged from #29722 onto current main.
This commit is contained in:
+137
-58
@@ -435,6 +435,9 @@ def run_conversation(
|
||||
# state registry. Set BEFORE any tool dispatch so snapshots taken at
|
||||
# child-launch time see the parent's real id, not None.
|
||||
agent._current_task_id = effective_task_id
|
||||
turn_id = f"{agent.session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}"
|
||||
agent._current_turn_id = turn_id
|
||||
agent._current_api_request_id = ""
|
||||
|
||||
# Reset retry counters and iteration budget at the start of each turn
|
||||
# so subagent usage from a previous turn doesn't eat into the next one.
|
||||
@@ -702,6 +705,8 @@ def run_conversation(
|
||||
_pre_results = _invoke_hook(
|
||||
"pre_llm_call",
|
||||
session_id=agent.session_id,
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
user_message=original_user_message,
|
||||
conversation_history=list(messages),
|
||||
is_first_turn=(not bool(conversation_history)),
|
||||
@@ -1153,6 +1158,8 @@ def run_conversation(
|
||||
finish_reason = "stop"
|
||||
response = None # Guard against UnboundLocalError if all retries fail
|
||||
api_kwargs = None # Guard against UnboundLocalError in except handler
|
||||
api_request_id = f"{turn_id}:api:{api_call_count}"
|
||||
agent._current_api_request_id = api_request_id
|
||||
|
||||
while retry_count < max_retries:
|
||||
# ── Nous Portal rate limit guard ──────────────────────
|
||||
@@ -1220,37 +1227,58 @@ def run_conversation(
|
||||
api_kwargs = agent._get_transport().preflight_kwargs(api_kwargs, allow_stream=False)
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||
request_messages = api_kwargs.get("messages")
|
||||
if not isinstance(request_messages, list):
|
||||
request_messages = api_kwargs.get("input")
|
||||
if not isinstance(request_messages, list):
|
||||
request_messages = api_messages
|
||||
# Shallow-copy the outer list so plugins that retain the
|
||||
# reference for async snapshotting don't observe later
|
||||
# mutations of api_messages. The inner dicts are not
|
||||
# mutated by the agent loop, so a shallow copy is
|
||||
# sufficient; a deepcopy would walk every tool result
|
||||
# and base64 image on every API call.
|
||||
_invoke_hook(
|
||||
"pre_api_request",
|
||||
task_id=effective_task_id,
|
||||
session_id=agent.session_id or "",
|
||||
user_message=original_user_message,
|
||||
conversation_history=list(messages),
|
||||
platform=agent.platform or "",
|
||||
model=agent.model,
|
||||
provider=agent.provider,
|
||||
base_url=agent.base_url,
|
||||
api_mode=agent.api_mode,
|
||||
api_call_count=api_call_count,
|
||||
request_messages=list(request_messages) if isinstance(request_messages, list) else [],
|
||||
message_count=len(api_messages),
|
||||
tool_count=len(agent.tools or []),
|
||||
approx_input_tokens=approx_tokens,
|
||||
request_char_count=total_chars,
|
||||
max_tokens=agent.max_tokens,
|
||||
from hermes_cli.plugins import (
|
||||
has_hook,
|
||||
invoke_hook as _invoke_hook,
|
||||
)
|
||||
if has_hook("pre_api_request"):
|
||||
request_messages = api_kwargs.get("messages")
|
||||
if not isinstance(request_messages, list):
|
||||
request_messages = api_kwargs.get("input")
|
||||
if not isinstance(request_messages, list):
|
||||
request_messages = api_messages
|
||||
# Shallow-copy the outer list so plugins that retain the
|
||||
# reference for async snapshotting don't observe later
|
||||
# mutations of api_messages. The inner dicts are not
|
||||
# mutated by the agent loop, so a shallow copy is
|
||||
# sufficient; a deepcopy would walk every tool result
|
||||
# and base64 image on every API call.
|
||||
#
|
||||
# The ``request_messages`` and ``conversation_history``
|
||||
# kwargs below are pre-existing raw passthroughs
|
||||
# consumed by the bundled langfuse plugin
|
||||
# (``plugins/observability/langfuse/__init__.py:_coerce_request_messages``).
|
||||
# They predate ``request`` and are intentionally NOT
|
||||
# sanitised — secrets are not expected here because
|
||||
# ``api_kwargs`` is the same object passed to the
|
||||
# provider client. New consumers should read the
|
||||
# sanitised view from ``request["body"]["messages"]``.
|
||||
_request_payload = agent._api_request_payload_for_hook(api_kwargs)
|
||||
_invoke_hook(
|
||||
"pre_api_request",
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
session_id=agent.session_id or "",
|
||||
user_message=original_user_message,
|
||||
conversation_history=list(messages),
|
||||
platform=agent.platform or "",
|
||||
model=agent.model,
|
||||
provider=agent.provider,
|
||||
base_url=agent.base_url,
|
||||
api_mode=agent.api_mode,
|
||||
api_call_count=api_call_count,
|
||||
request_messages=list(request_messages)
|
||||
if isinstance(request_messages, list)
|
||||
else [],
|
||||
message_count=len(api_messages),
|
||||
tool_count=len(agent.tools or []),
|
||||
approx_input_tokens=approx_tokens,
|
||||
request_char_count=total_chars,
|
||||
max_tokens=agent.max_tokens,
|
||||
started_at=api_start_time,
|
||||
request=_request_payload,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1300,12 +1328,14 @@ def run_conversation(
|
||||
if isinstance(getattr(agent, "client", None), Mock):
|
||||
_use_streaming = False
|
||||
|
||||
if _use_streaming:
|
||||
response = agent._interruptible_streaming_api_call(
|
||||
api_kwargs, on_first_delta=_stop_spinner
|
||||
)
|
||||
else:
|
||||
response = agent._interruptible_api_call(api_kwargs)
|
||||
def _perform_api_call(next_api_kwargs):
|
||||
if _use_streaming:
|
||||
return agent._interruptible_streaming_api_call(
|
||||
next_api_kwargs, on_first_delta=_stop_spinner
|
||||
)
|
||||
return agent._interruptible_api_call(next_api_kwargs)
|
||||
|
||||
response = _perform_api_call(api_kwargs)
|
||||
|
||||
api_duration = time.time() - api_start_time
|
||||
|
||||
@@ -1406,6 +1436,21 @@ def run_conversation(
|
||||
error_details.append("response.choices is empty")
|
||||
|
||||
if response_invalid:
|
||||
agent._invoke_api_request_error_hook(
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
api_call_count=api_call_count,
|
||||
api_start_time=api_start_time,
|
||||
api_kwargs=api_kwargs,
|
||||
error_type="InvalidAPIResponse",
|
||||
error_message=", ".join(error_details) or "Invalid API response",
|
||||
status_code=getattr(getattr(response, "error", None), "code", None),
|
||||
retry_count=retry_count,
|
||||
max_retries=max_retries,
|
||||
retryable=True,
|
||||
reason="invalid_response",
|
||||
)
|
||||
# Stop spinner silently — retry status is now buffered
|
||||
# and only surfaced if every retry+fallback exhausts.
|
||||
if thinking_spinner:
|
||||
@@ -2278,6 +2323,21 @@ def run_conversation(
|
||||
classified.retryable, classified.should_compress,
|
||||
classified.should_rotate_credential, classified.should_fallback,
|
||||
)
|
||||
agent._invoke_api_request_error_hook(
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
api_call_count=api_call_count,
|
||||
api_start_time=api_start_time,
|
||||
api_kwargs=api_kwargs,
|
||||
error_type=type(api_error).__name__,
|
||||
error_message=str(api_error),
|
||||
status_code=status_code,
|
||||
retry_count=retry_count,
|
||||
max_retries=max_retries,
|
||||
retryable=classified.retryable,
|
||||
reason=classified.reason.value,
|
||||
)
|
||||
|
||||
if (
|
||||
classified.reason == FailoverReason.billing
|
||||
@@ -3507,29 +3567,44 @@ def run_conversation(
|
||||
assistant_message.content = str(raw)
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||
_assistant_tool_calls = getattr(assistant_message, "tool_calls", None) or []
|
||||
_assistant_text = assistant_message.content or ""
|
||||
_invoke_hook(
|
||||
"post_api_request",
|
||||
task_id=effective_task_id,
|
||||
session_id=agent.session_id or "",
|
||||
platform=agent.platform or "",
|
||||
model=agent.model,
|
||||
provider=agent.provider,
|
||||
base_url=agent.base_url,
|
||||
api_mode=agent.api_mode,
|
||||
api_call_count=api_call_count,
|
||||
api_duration=api_duration,
|
||||
finish_reason=finish_reason,
|
||||
message_count=len(api_messages),
|
||||
response_model=getattr(response, "model", None),
|
||||
response=response,
|
||||
usage=agent._usage_summary_for_api_request_hook(response),
|
||||
assistant_message=assistant_message,
|
||||
assistant_content_chars=len(_assistant_text),
|
||||
assistant_tool_call_count=len(_assistant_tool_calls),
|
||||
from hermes_cli.plugins import (
|
||||
has_hook,
|
||||
invoke_hook as _invoke_hook,
|
||||
)
|
||||
if has_hook("post_api_request"):
|
||||
_assistant_tool_calls = (
|
||||
getattr(assistant_message, "tool_calls", None) or []
|
||||
)
|
||||
_assistant_text = assistant_message.content or ""
|
||||
_api_ended_at = api_start_time + api_duration
|
||||
_invoke_hook(
|
||||
"post_api_request",
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
session_id=agent.session_id or "",
|
||||
platform=agent.platform or "",
|
||||
model=agent.model,
|
||||
provider=agent.provider,
|
||||
base_url=agent.base_url,
|
||||
api_mode=agent.api_mode,
|
||||
api_call_count=api_call_count,
|
||||
api_duration=api_duration,
|
||||
started_at=api_start_time,
|
||||
ended_at=_api_ended_at,
|
||||
finish_reason=finish_reason,
|
||||
message_count=len(api_messages),
|
||||
response_model=getattr(response, "model", None),
|
||||
response=agent._api_response_payload_for_hook(
|
||||
response,
|
||||
assistant_message,
|
||||
finish_reason=finish_reason,
|
||||
),
|
||||
usage=agent._usage_summary_for_api_request_hook(response),
|
||||
assistant_message=assistant_message,
|
||||
assistant_content_chars=len(_assistant_text),
|
||||
assistant_tool_call_count=len(_assistant_tool_calls),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -4623,6 +4698,8 @@ def run_conversation(
|
||||
_invoke_hook(
|
||||
"post_llm_call",
|
||||
session_id=agent.session_id,
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
user_message=original_user_message,
|
||||
assistant_response=final_response,
|
||||
conversation_history=list(messages),
|
||||
@@ -4742,6 +4819,8 @@ def run_conversation(
|
||||
_invoke_hook(
|
||||
"on_session_end",
|
||||
session_id=agent.session_id,
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
completed=completed,
|
||||
interrupted=interrupted,
|
||||
model=agent.model,
|
||||
|
||||
Reference in New Issue
Block a user