traceAI-hermes-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- traceai_hermes_agent/__init__.py +232 -0
- traceai_hermes_agent/_recorder.py +605 -0
- traceai_hermes_agent/py.typed +0 -0
- traceai_hermes_agent/version.py +1 -0
- traceai_hermes_agent-0.1.0.dist-info/METADATA +203 -0
- traceai_hermes_agent-0.1.0.dist-info/RECORD +8 -0
- traceai_hermes_agent-0.1.0.dist-info/WHEEL +4 -0
- traceai_hermes_agent-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Future AGI tracing plugin for Hermes Agent.
|
|
2
|
+
|
|
3
|
+
Hermes loads ``register(ctx)`` from the ``hermes_agent.plugins`` entry point
|
|
4
|
+
``futureagi`` once the plugin is enabled (``hermes plugins enable futureagi``).
|
|
5
|
+
It registers observer callbacks on Hermes' documented plugin hooks and exports
|
|
6
|
+
one OpenTelemetry trace per Hermes turn to Future AGI.
|
|
7
|
+
|
|
8
|
+
Configuration (``$HERMES_HOME/.env`` works; in a multi-profile gateway each
|
|
9
|
+
profile's own ``.env`` is used):
|
|
10
|
+
|
|
11
|
+
- ``FI_API_KEY`` / ``FI_SECRET_KEY``: required. Without them the plugin stays a no-op.
|
|
12
|
+
- ``FI_BASE_URL``: collector base URL (default ``https://api.futureagi.com``).
|
|
13
|
+
- ``FI_PROJECT_NAME``: Future AGI project (default ``hermes-agent``).
|
|
14
|
+
- ``FI_HERMES_CAPTURE_CONTENT``: ``true`` to also export prompts, responses,
|
|
15
|
+
tool arguments, tool results and error text. Off by default.
|
|
16
|
+
- ``FI_HERMES_MAX_CHARS``: per-attribute cap on captured text (default 8000).
|
|
17
|
+
|
|
18
|
+
The plugin never reads Hermes' session files, config or memory: everything it
|
|
19
|
+
exports comes from the hook payloads. It installs no signal handlers and does
|
|
20
|
+
not touch the global OpenTelemetry provider.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import atexit
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import threading
|
|
30
|
+
import uuid
|
|
31
|
+
from typing import Any, Callable, Dict, Optional
|
|
32
|
+
|
|
33
|
+
from opentelemetry import trace as trace_api
|
|
34
|
+
|
|
35
|
+
from traceai_hermes_agent._recorder import HOOKS, HermesSpanRecorder
|
|
36
|
+
from traceai_hermes_agent.version import __version__
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
logger.addHandler(logging.NullHandler())
|
|
40
|
+
|
|
41
|
+
DEFAULT_PROJECT_NAME = "hermes-agent"
|
|
42
|
+
DEFAULT_BASE_URL = "https://api.futureagi.com"
|
|
43
|
+
_TRUE = {"1", "true", "yes", "on"}
|
|
44
|
+
# Exits and session teardown wait at most this long for the last spans.
|
|
45
|
+
EXPORT_TIMEOUT_SECONDS = 3
|
|
46
|
+
FLUSH_TIMEOUT_MILLIS = 2000
|
|
47
|
+
# Hooks that may create the provider. on_session_finalize only flushes: it can
|
|
48
|
+
# run on the main thread at exit, outside any profile's secret scope.
|
|
49
|
+
_BUILD_HOOKS = frozenset(HOOKS) - {"on_session_finalize", "subagent_stop"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _NotInScope(Exception):
|
|
53
|
+
"""A multi-profile gateway asked for a secret outside any profile's scope."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _setting(name: str) -> Optional[str]:
|
|
57
|
+
"""Read one setting the way Hermes reads profile secrets.
|
|
58
|
+
|
|
59
|
+
In a multi-profile gateway every profile's ``.env`` is kept out of
|
|
60
|
+
``os.environ`` and served through ``agent.secret_scope``; outside Hermes
|
|
61
|
+
(or in a normal single-profile run) this is ``os.environ``.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
from agent.secret_scope import UnscopedSecretError, get_secret
|
|
65
|
+
except Exception:
|
|
66
|
+
return os.environ.get(name)
|
|
67
|
+
try:
|
|
68
|
+
return get_secret(name)
|
|
69
|
+
except UnscopedSecretError as exc:
|
|
70
|
+
raise _NotInScope(name) from exc
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _flag(value: Optional[str]) -> bool:
|
|
74
|
+
return (value or "").strip().lower() in _TRUE
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _int(value: Optional[str], default: int) -> int:
|
|
78
|
+
try:
|
|
79
|
+
return int((value or "").strip() or default)
|
|
80
|
+
except ValueError:
|
|
81
|
+
return default
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_tracer_provider(*, api_key: str, secret_key: str, base_url: str,
|
|
85
|
+
project_name: str) -> Any:
|
|
86
|
+
"""A private TracerProvider exporting to ``$base_url/tracer/v1/traces``.
|
|
87
|
+
|
|
88
|
+
Built directly instead of through ``fi_instrumentation.register()``, which
|
|
89
|
+
installs SIGINT/SIGTERM handlers and reads keys from ``os.environ``:
|
|
90
|
+
neither is acceptable inside the Hermes process. Resource attributes match
|
|
91
|
+
what ``register()`` sends for an observe project.
|
|
92
|
+
"""
|
|
93
|
+
from opentelemetry.exporter.otlp.proto.http import Compression
|
|
94
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
95
|
+
from opentelemetry.sdk.resources import Resource
|
|
96
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
97
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
98
|
+
|
|
99
|
+
resource = Resource(attributes={
|
|
100
|
+
"project_name": project_name,
|
|
101
|
+
"project_type": "observe",
|
|
102
|
+
"project_version_name": "DEFAULT_PROJECT_VERSION_NAME",
|
|
103
|
+
"project_version_id": str(uuid.uuid4()),
|
|
104
|
+
"eval_tags": json.dumps([]),
|
|
105
|
+
"metadata": json.dumps({}),
|
|
106
|
+
"semantic_convention": "fi",
|
|
107
|
+
"service.name": "hermes-agent",
|
|
108
|
+
})
|
|
109
|
+
exporter = OTLPSpanExporter(
|
|
110
|
+
endpoint=base_url.rstrip("/") + "/tracer/v1/traces",
|
|
111
|
+
headers={"X-Api-Key": api_key, "X-Secret-Key": secret_key},
|
|
112
|
+
timeout=EXPORT_TIMEOUT_SECONDS,
|
|
113
|
+
# The Future AGI collector does not read Content-Encoding.
|
|
114
|
+
compression=Compression.NoCompression,
|
|
115
|
+
)
|
|
116
|
+
provider = TracerProvider(resource=resource)
|
|
117
|
+
provider.add_span_processor(BatchSpanProcessor(exporter, export_timeout_millis=FLUSH_TIMEOUT_MILLIS))
|
|
118
|
+
return provider
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class FutureAGIHermesPlugin:
|
|
122
|
+
"""Owns the tracer provider and the recorder; built lazily on the first turn hook.
|
|
123
|
+
|
|
124
|
+
Lazy so that ``hermes plugins list`` and other commands that only import
|
|
125
|
+
plugins never start an exporter, so that a missing key is reported once,
|
|
126
|
+
and so that in a multi-profile gateway the settings are read inside the
|
|
127
|
+
profile's own secret scope. Hermes creates one plugin instance per profile.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(self, tracer_provider: Optional[Any] = None) -> None:
|
|
131
|
+
self._provider = tracer_provider
|
|
132
|
+
self._owns_provider = tracer_provider is None
|
|
133
|
+
self._recorder: Optional[HermesSpanRecorder] = None
|
|
134
|
+
self._disabled = False
|
|
135
|
+
self._lock = threading.Lock()
|
|
136
|
+
self.settings: Dict[str, Any] = {}
|
|
137
|
+
|
|
138
|
+
def recorder(self, build: bool = True) -> Optional[HermesSpanRecorder]:
|
|
139
|
+
if self._recorder is not None or self._disabled or not build:
|
|
140
|
+
return self._recorder
|
|
141
|
+
with self._lock:
|
|
142
|
+
if self._recorder is not None or self._disabled:
|
|
143
|
+
return self._recorder
|
|
144
|
+
try:
|
|
145
|
+
self._recorder = self._build()
|
|
146
|
+
except _NotInScope:
|
|
147
|
+
return None # try again on a hook that runs in a profile's scope
|
|
148
|
+
except Exception:
|
|
149
|
+
logger.warning("traceAI-hermes-agent: tracing disabled, setup failed", exc_info=True)
|
|
150
|
+
self._recorder = None
|
|
151
|
+
if self._recorder is None:
|
|
152
|
+
self._disabled = True
|
|
153
|
+
return self._recorder
|
|
154
|
+
|
|
155
|
+
def _build(self) -> Optional[HermesSpanRecorder]:
|
|
156
|
+
capture = _flag(_setting("FI_HERMES_CAPTURE_CONTENT"))
|
|
157
|
+
max_chars = _int(_setting("FI_HERMES_MAX_CHARS"), 8000)
|
|
158
|
+
if self._provider is None:
|
|
159
|
+
api_key, secret_key = _setting("FI_API_KEY"), _setting("FI_SECRET_KEY")
|
|
160
|
+
if not (api_key and secret_key):
|
|
161
|
+
logger.warning(
|
|
162
|
+
"traceAI-hermes-agent: FI_API_KEY and FI_SECRET_KEY are not set; "
|
|
163
|
+
"Hermes turns are not exported"
|
|
164
|
+
)
|
|
165
|
+
return None
|
|
166
|
+
self.settings = {
|
|
167
|
+
"base_url": _setting("FI_BASE_URL") or DEFAULT_BASE_URL,
|
|
168
|
+
"project_name": _setting("FI_PROJECT_NAME") or DEFAULT_PROJECT_NAME,
|
|
169
|
+
}
|
|
170
|
+
self._provider = build_tracer_provider(api_key=api_key, secret_key=secret_key,
|
|
171
|
+
**self.settings)
|
|
172
|
+
atexit.register(self.shutdown)
|
|
173
|
+
self.settings.update(capture_content=capture, max_chars=max_chars)
|
|
174
|
+
tracer = trace_api.get_tracer(__name__, __version__, tracer_provider=self._provider)
|
|
175
|
+
return HermesSpanRecorder(tracer, capture_content=capture, max_chars=max_chars)
|
|
176
|
+
|
|
177
|
+
def callback(self, hook: str) -> Callable[..., None]:
|
|
178
|
+
method = "on_" + hook if not hook.startswith("on_") else hook
|
|
179
|
+
build = hook in _BUILD_HOOKS
|
|
180
|
+
# A long-lived gateway exports a session as soon as Hermes tears it down.
|
|
181
|
+
flush_after = hook == "on_session_finalize"
|
|
182
|
+
|
|
183
|
+
def _callback(**kwargs: Any) -> None:
|
|
184
|
+
try:
|
|
185
|
+
recorder = self.recorder(build=build)
|
|
186
|
+
if recorder is not None:
|
|
187
|
+
getattr(recorder, method)(**kwargs)
|
|
188
|
+
if flush_after:
|
|
189
|
+
self.flush()
|
|
190
|
+
except Exception: # never let tracing break Hermes
|
|
191
|
+
logger.debug("traceAI-hermes-agent: %s callback failed", hook, exc_info=True)
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
_callback.__name__ = "futureagi_" + hook
|
|
195
|
+
return _callback
|
|
196
|
+
|
|
197
|
+
def flush(self) -> None:
|
|
198
|
+
provider = self._provider
|
|
199
|
+
if provider is not None and hasattr(provider, "force_flush"):
|
|
200
|
+
try:
|
|
201
|
+
provider.force_flush(timeout_millis=FLUSH_TIMEOUT_MILLIS)
|
|
202
|
+
except Exception:
|
|
203
|
+
logger.debug("traceAI-hermes-agent: flush failed", exc_info=True)
|
|
204
|
+
|
|
205
|
+
def shutdown(self) -> None:
|
|
206
|
+
"""Close open spans (marked unfinished) and export them; runs at process exit.
|
|
207
|
+
|
|
208
|
+
Bounded: an unreachable collector delays exit by at most a few seconds.
|
|
209
|
+
"""
|
|
210
|
+
if self._recorder is not None:
|
|
211
|
+
self._recorder.end_all()
|
|
212
|
+
if self._owns_provider and self._provider is not None and hasattr(self._provider, "shutdown"):
|
|
213
|
+
try:
|
|
214
|
+
self._provider.shutdown()
|
|
215
|
+
except Exception:
|
|
216
|
+
logger.debug("traceAI-hermes-agent: shutdown failed", exc_info=True)
|
|
217
|
+
else:
|
|
218
|
+
self.flush()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def register(ctx: Any, tracer_provider: Optional[Any] = None) -> None:
|
|
222
|
+
"""Hermes plugin entry point: register the tracing callbacks on ``ctx``.
|
|
223
|
+
|
|
224
|
+
``tracer_provider`` is for tests and embedders; Hermes calls ``register(ctx)``.
|
|
225
|
+
"""
|
|
226
|
+
plugin = FutureAGIHermesPlugin(tracer_provider)
|
|
227
|
+
for hook in HOOKS:
|
|
228
|
+
ctx.register_hook(hook, plugin.callback(hook))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
__all__ = ["FutureAGIHermesPlugin", "HermesSpanRecorder", "build_tracer_provider", "register",
|
|
232
|
+
"__version__"]
|
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
"""Turn Hermes Agent plugin-hook payloads into OpenTelemetry spans.
|
|
2
|
+
|
|
3
|
+
One trace per Hermes turn:
|
|
4
|
+
|
|
5
|
+
hermes.turn AGENT pre_llm_call ... on_session_end
|
|
6
|
+
chat <model> LLM pre_api_request ... post_api_request / api_request_error
|
|
7
|
+
<tool name> TOOL post_tool_call (start = end - duration_ms)
|
|
8
|
+
|
|
9
|
+
Every turn carries ``session.id`` (the Hermes session id), so the turns of one
|
|
10
|
+
``hermes chat`` session group under one Future AGI session.
|
|
11
|
+
|
|
12
|
+
Hooks can fire on Hermes worker threads, so spans are tracked by the ids the
|
|
13
|
+
hooks carry (turn_id, api_request_id, tool_call_id) and parented explicitly,
|
|
14
|
+
never through the ambient OpenTelemetry context.
|
|
15
|
+
|
|
16
|
+
Prompt, response, tool-argument, tool-result and error text is only read
|
|
17
|
+
when ``capture_content`` is true. With it off, the only things exported are
|
|
18
|
+
ids, model and provider names, token counts, sizes, durations, outcomes and
|
|
19
|
+
Hermes' error categories (never the error messages).
|
|
20
|
+
|
|
21
|
+
The plugin observes only: it does not register on ``pre_tool_call``, which
|
|
22
|
+
Hermes treats as a fail-closed policy hook.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
|
33
|
+
from urllib.parse import urlparse
|
|
34
|
+
|
|
35
|
+
from opentelemetry import context as context_api
|
|
36
|
+
from opentelemetry import trace as trace_api
|
|
37
|
+
from opentelemetry.trace import Span, Status, StatusCode
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
SPAN_KIND = "gen_ai.span.kind"
|
|
42
|
+
OPERATION = "gen_ai.operation.name"
|
|
43
|
+
SESSION_ID = "session.id"
|
|
44
|
+
CONVERSATION_ID = "gen_ai.conversation.id"
|
|
45
|
+
INPUT_VALUE = "input.value"
|
|
46
|
+
INPUT_MIME = "input.mime_type"
|
|
47
|
+
OUTPUT_VALUE = "output.value"
|
|
48
|
+
OUTPUT_MIME = "output.mime_type"
|
|
49
|
+
JSON_MIME = "application/json"
|
|
50
|
+
TEXT_MIME = "text/plain"
|
|
51
|
+
|
|
52
|
+
TURN_SPAN_NAME = "hermes.turn"
|
|
53
|
+
# Tool statuses Hermes reports on post_tool_call that are not a success.
|
|
54
|
+
_FAILED_TOOL_STATUSES = frozenset({"error", "blocked", "timeout", "cancelled"})
|
|
55
|
+
_TRUNCATED = "...[truncated {0} chars]"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class _Turn:
|
|
60
|
+
span: Span
|
|
61
|
+
session_id: str
|
|
62
|
+
children: List[str] = field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _ns(epoch_seconds: Any) -> Optional[int]:
|
|
66
|
+
"""Epoch seconds (Hermes' started_at / ended_at) to OTel nanoseconds."""
|
|
67
|
+
if isinstance(epoch_seconds, bool) or not isinstance(epoch_seconds, (int, float)):
|
|
68
|
+
return None
|
|
69
|
+
if epoch_seconds <= 0:
|
|
70
|
+
return None
|
|
71
|
+
return int(epoch_seconds * 1_000_000_000)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _str(value: Any) -> str:
|
|
75
|
+
return value if isinstance(value, str) else ("" if value is None else str(value))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _int(value: Any) -> Optional[int]:
|
|
79
|
+
if isinstance(value, bool):
|
|
80
|
+
return None
|
|
81
|
+
if isinstance(value, int):
|
|
82
|
+
return value
|
|
83
|
+
if isinstance(value, float) and value.is_integer():
|
|
84
|
+
return int(value)
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _host(url: Any) -> Optional[str]:
|
|
89
|
+
"""Only the host of a provider base URL; a URL can carry credentials."""
|
|
90
|
+
if not isinstance(url, str) or not url:
|
|
91
|
+
return None
|
|
92
|
+
try:
|
|
93
|
+
return urlparse(url).hostname
|
|
94
|
+
except ValueError:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _set(span: Span, attributes: Mapping[str, Any]) -> None:
|
|
99
|
+
for key, value in attributes.items():
|
|
100
|
+
if value is None or value == "":
|
|
101
|
+
continue
|
|
102
|
+
span.set_attribute(key, value)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class HermesSpanRecorder:
|
|
106
|
+
"""Maps the documented Hermes plugin hooks to spans on one tracer.
|
|
107
|
+
|
|
108
|
+
Each ``on_*`` method takes the hook's keyword arguments. Methods never raise
|
|
109
|
+
and always return None (never a directive), so a recorder bug cannot change
|
|
110
|
+
what Hermes does.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, tracer: trace_api.Tracer, *, capture_content: bool = False,
|
|
114
|
+
max_chars: int = 8000) -> None:
|
|
115
|
+
self._tracer = tracer
|
|
116
|
+
self._capture = bool(capture_content)
|
|
117
|
+
self._max_chars = max(256, int(max_chars))
|
|
118
|
+
self._lock = threading.RLock()
|
|
119
|
+
self._turns: Dict[str, _Turn] = {}
|
|
120
|
+
# open LLM/tool spans: key -> (span, turn key)
|
|
121
|
+
self._children: Dict[str, tuple] = {}
|
|
122
|
+
# session id -> most recent open turn key (subagent parenting, session end)
|
|
123
|
+
self._session_turn: Dict[str, str] = {}
|
|
124
|
+
# child session id -> parent turn key (from subagent_start)
|
|
125
|
+
self._subagent_parent: Dict[str, str] = {}
|
|
126
|
+
# api_request_ids whose earlier attempt already ended (retries)
|
|
127
|
+
self._attempted: Dict[str, int] = {}
|
|
128
|
+
# LLM span key -> start time in epoch seconds (time to first chunk)
|
|
129
|
+
self._llm_start: Dict[str, float] = {}
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def capture_content(self) -> bool:
|
|
133
|
+
return self._capture
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------ helpers
|
|
136
|
+
|
|
137
|
+
def _text(self, value: Any) -> Optional[str]:
|
|
138
|
+
if value is None:
|
|
139
|
+
return None
|
|
140
|
+
if isinstance(value, str):
|
|
141
|
+
text = value
|
|
142
|
+
else:
|
|
143
|
+
try:
|
|
144
|
+
text = json.dumps(value, ensure_ascii=False, default=str)
|
|
145
|
+
except (TypeError, ValueError):
|
|
146
|
+
text = str(value)
|
|
147
|
+
if len(text) > self._max_chars:
|
|
148
|
+
text = text[: self._max_chars] + _TRUNCATED.format(len(text) - self._max_chars)
|
|
149
|
+
return text
|
|
150
|
+
|
|
151
|
+
def _content(self, span: Span, key: str, mime_key: str, value: Any) -> None:
|
|
152
|
+
if not self._capture or value is None:
|
|
153
|
+
return
|
|
154
|
+
text = self._text(value)
|
|
155
|
+
if text is None:
|
|
156
|
+
return
|
|
157
|
+
span.set_attribute(key, text)
|
|
158
|
+
span.set_attribute(mime_key, TEXT_MIME if isinstance(value, str) else JSON_MIME)
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _turn_key(kw: Mapping[str, Any]) -> str:
|
|
162
|
+
turn_id = _str(kw.get("turn_id"))
|
|
163
|
+
if turn_id:
|
|
164
|
+
return turn_id
|
|
165
|
+
return "{0}:{1}".format(_str(kw.get("session_id")), _str(kw.get("task_id")))
|
|
166
|
+
|
|
167
|
+
def _ensure_turn(self, kw: Mapping[str, Any]) -> _Turn:
|
|
168
|
+
key = self._turn_key(kw)
|
|
169
|
+
session_id = _str(kw.get("session_id"))
|
|
170
|
+
with self._lock:
|
|
171
|
+
turn = self._turns.get(key)
|
|
172
|
+
if turn is not None:
|
|
173
|
+
return turn
|
|
174
|
+
parent_key = self._subagent_parent.get(session_id)
|
|
175
|
+
parent_session = _str(kw.get("parent_session_id"))
|
|
176
|
+
if parent_key is None and parent_session:
|
|
177
|
+
parent_key = self._session_turn.get(parent_session)
|
|
178
|
+
parent = self._turns.get(parent_key) if parent_key else None
|
|
179
|
+
# An empty Context makes this a root span even if something else
|
|
180
|
+
# in the Hermes process left a span current on this thread.
|
|
181
|
+
ctx = (trace_api.set_span_in_context(parent.span) if parent is not None
|
|
182
|
+
else context_api.Context())
|
|
183
|
+
span = self._tracer.start_span(TURN_SPAN_NAME, context=ctx)
|
|
184
|
+
_set(span, {
|
|
185
|
+
SPAN_KIND: "AGENT",
|
|
186
|
+
OPERATION: "invoke_agent",
|
|
187
|
+
"gen_ai.agent.name": "hermes",
|
|
188
|
+
SESSION_ID: session_id,
|
|
189
|
+
CONVERSATION_ID: session_id,
|
|
190
|
+
"hermes.session_id": session_id,
|
|
191
|
+
"hermes.turn_id": _str(kw.get("turn_id")),
|
|
192
|
+
"hermes.task_id": _str(kw.get("task_id")),
|
|
193
|
+
"hermes.platform": _str(kw.get("platform")),
|
|
194
|
+
"hermes.model": _str(kw.get("model")),
|
|
195
|
+
"hermes.parent_session_id": parent_session,
|
|
196
|
+
})
|
|
197
|
+
if isinstance(kw.get("is_first_turn"), bool):
|
|
198
|
+
span.set_attribute("hermes.is_first_turn", kw["is_first_turn"])
|
|
199
|
+
turn = _Turn(span=span, session_id=session_id)
|
|
200
|
+
self._turns[key] = turn
|
|
201
|
+
if session_id:
|
|
202
|
+
self._session_turn[session_id] = key
|
|
203
|
+
return turn
|
|
204
|
+
|
|
205
|
+
def _start_child(self, key: str, name: str, kw: Mapping[str, Any],
|
|
206
|
+
start_time: Optional[int] = None) -> Span:
|
|
207
|
+
turn = self._ensure_turn(kw)
|
|
208
|
+
span = self._tracer.start_span(
|
|
209
|
+
name, context=trace_api.set_span_in_context(turn.span), start_time=start_time,
|
|
210
|
+
)
|
|
211
|
+
if turn.session_id:
|
|
212
|
+
span.set_attribute(SESSION_ID, turn.session_id)
|
|
213
|
+
with self._lock:
|
|
214
|
+
stale = self._children.pop(key, None)
|
|
215
|
+
self._children[key] = (span, self._turn_key(kw))
|
|
216
|
+
turn.children.append(key)
|
|
217
|
+
if stale is not None:
|
|
218
|
+
# Same id started twice without an end hook: close the first one.
|
|
219
|
+
stale[0].set_attribute("hermes.unfinished", True)
|
|
220
|
+
stale[0].end()
|
|
221
|
+
return span
|
|
222
|
+
|
|
223
|
+
def _pop_child(self, key: str) -> Optional[Span]:
|
|
224
|
+
with self._lock:
|
|
225
|
+
entry = self._children.pop(key, None)
|
|
226
|
+
return entry[0] if entry else None
|
|
227
|
+
|
|
228
|
+
def _end_turn(self, key: str, *, attributes: Mapping[str, Any],
|
|
229
|
+
status: Optional[Status] = None) -> None:
|
|
230
|
+
with self._lock:
|
|
231
|
+
turn = self._turns.pop(key, None)
|
|
232
|
+
if turn is None:
|
|
233
|
+
return
|
|
234
|
+
if self._session_turn.get(turn.session_id) == key:
|
|
235
|
+
del self._session_turn[turn.session_id]
|
|
236
|
+
orphans = [self._children.pop(k)[0] for k in turn.children if k in self._children]
|
|
237
|
+
for child in turn.children:
|
|
238
|
+
if child.startswith("llm:"):
|
|
239
|
+
self._attempted.pop(child[4:], None)
|
|
240
|
+
self._llm_start.pop(child, None)
|
|
241
|
+
for span in orphans:
|
|
242
|
+
span.set_attribute("hermes.unfinished", True)
|
|
243
|
+
span.end()
|
|
244
|
+
_set(turn.span, attributes)
|
|
245
|
+
if status is not None:
|
|
246
|
+
turn.span.set_status(status)
|
|
247
|
+
turn.span.end()
|
|
248
|
+
|
|
249
|
+
def _guard(self, hook: str, fn, kw: Mapping[str, Any]) -> None:
|
|
250
|
+
try:
|
|
251
|
+
fn(kw)
|
|
252
|
+
except Exception: # never let tracing break the agent loop
|
|
253
|
+
logger.debug("traceAI-hermes-agent: %s handler failed", hook, exc_info=True)
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
# -------------------------------------------------------------------- turns
|
|
257
|
+
|
|
258
|
+
def on_pre_llm_call(self, **kw: Any) -> None:
|
|
259
|
+
return self._guard("pre_llm_call", self._pre_llm_call, kw)
|
|
260
|
+
|
|
261
|
+
def _pre_llm_call(self, kw: Mapping[str, Any]) -> None:
|
|
262
|
+
turn = self._ensure_turn(kw)
|
|
263
|
+
self._content(turn.span, INPUT_VALUE, INPUT_MIME, kw.get("user_message"))
|
|
264
|
+
|
|
265
|
+
def on_post_llm_call(self, **kw: Any) -> None:
|
|
266
|
+
return self._guard("post_llm_call", self._post_llm_call, kw)
|
|
267
|
+
|
|
268
|
+
def _post_llm_call(self, kw: Mapping[str, Any]) -> None:
|
|
269
|
+
with self._lock:
|
|
270
|
+
turn = self._turns.get(self._turn_key(kw))
|
|
271
|
+
if turn is not None:
|
|
272
|
+
self._content(turn.span, OUTPUT_VALUE, OUTPUT_MIME, kw.get("assistant_response"))
|
|
273
|
+
|
|
274
|
+
def on_session_end(self, **kw: Any) -> None:
|
|
275
|
+
return self._guard("on_session_end", self._session_end, kw)
|
|
276
|
+
|
|
277
|
+
def _exit_reason(self, raw: Any) -> str:
|
|
278
|
+
"""Hermes' exit reason, or only its category unless content capture is on.
|
|
279
|
+
|
|
280
|
+
Hermes embeds exception text in some reasons, e.g.
|
|
281
|
+
``repeated_outer_errors(Error during API call #3: <str(e)>)``.
|
|
282
|
+
"""
|
|
283
|
+
reason = _str(raw).strip()
|
|
284
|
+
if self._capture or not reason:
|
|
285
|
+
return self._text(reason) or ""
|
|
286
|
+
return reason.split("(", 1)[0].strip()
|
|
287
|
+
|
|
288
|
+
def _session_end(self, kw: Mapping[str, Any]) -> None:
|
|
289
|
+
failed = kw.get("failed") is True
|
|
290
|
+
interrupted = kw.get("interrupted") is True
|
|
291
|
+
reason = self._exit_reason(kw.get("turn_exit_reason") or kw.get("reason"))
|
|
292
|
+
attributes = {
|
|
293
|
+
"hermes.completed": kw.get("completed") if isinstance(kw.get("completed"), bool) else None,
|
|
294
|
+
"hermes.failed": failed,
|
|
295
|
+
"hermes.interrupted": interrupted,
|
|
296
|
+
"hermes.turn_exit_reason": reason,
|
|
297
|
+
}
|
|
298
|
+
# Turns closed without this hook (session teardown, exit) stay UNSET.
|
|
299
|
+
status = Status(StatusCode.ERROR, reason or "turn failed") if failed else Status(StatusCode.OK)
|
|
300
|
+
if _str(kw.get("turn_id")):
|
|
301
|
+
self._end_turn(self._turn_key(kw), attributes=attributes, status=status)
|
|
302
|
+
return
|
|
303
|
+
# CLI/TUI exit shapes carry no turn_id: close whatever is open for the session.
|
|
304
|
+
self._end_session(_str(kw.get("session_id")), attributes=attributes, status=status)
|
|
305
|
+
|
|
306
|
+
def on_session_finalize(self, **kw: Any) -> None:
|
|
307
|
+
return self._guard("on_session_finalize", self._session_finalize, kw)
|
|
308
|
+
|
|
309
|
+
def _session_finalize(self, kw: Mapping[str, Any]) -> None:
|
|
310
|
+
for session_id in (kw.get("session_id"), kw.get("old_session_id")):
|
|
311
|
+
if session_id:
|
|
312
|
+
self._end_session(_str(session_id), attributes={"hermes.unfinished": True})
|
|
313
|
+
|
|
314
|
+
def _end_session(self, session_id: str, *, attributes: Mapping[str, Any],
|
|
315
|
+
status: Optional[Status] = None) -> None:
|
|
316
|
+
with self._lock:
|
|
317
|
+
keys = [k for k, t in self._turns.items() if t.session_id == session_id]
|
|
318
|
+
for key in keys:
|
|
319
|
+
self._end_turn(key, attributes=attributes, status=status)
|
|
320
|
+
|
|
321
|
+
def on_subagent_start(self, **kw: Any) -> None:
|
|
322
|
+
return self._guard("subagent_start", self._subagent_start, kw)
|
|
323
|
+
|
|
324
|
+
def _subagent_start(self, kw: Mapping[str, Any]) -> None:
|
|
325
|
+
child = _str(kw.get("child_session_id"))
|
|
326
|
+
parent_turn = _str(kw.get("parent_turn_id"))
|
|
327
|
+
if not child:
|
|
328
|
+
return
|
|
329
|
+
with self._lock:
|
|
330
|
+
if parent_turn and parent_turn in self._turns:
|
|
331
|
+
self._subagent_parent[child] = parent_turn
|
|
332
|
+
else:
|
|
333
|
+
parent_key = self._session_turn.get(_str(kw.get("parent_session_id")))
|
|
334
|
+
if parent_key:
|
|
335
|
+
self._subagent_parent[child] = parent_key
|
|
336
|
+
|
|
337
|
+
def on_subagent_stop(self, **kw: Any) -> None:
|
|
338
|
+
return self._guard("subagent_stop", self._subagent_stop, kw)
|
|
339
|
+
|
|
340
|
+
def _subagent_stop(self, kw: Mapping[str, Any]) -> None:
|
|
341
|
+
with self._lock:
|
|
342
|
+
self._subagent_parent.pop(_str(kw.get("child_session_id")), None)
|
|
343
|
+
|
|
344
|
+
# ---------------------------------------------------------------- LLM calls
|
|
345
|
+
|
|
346
|
+
@staticmethod
|
|
347
|
+
def _llm_key(kw: Mapping[str, Any]) -> str:
|
|
348
|
+
return "llm:" + _str(kw.get("api_request_id"))
|
|
349
|
+
|
|
350
|
+
def on_pre_api_request(self, **kw: Any) -> None:
|
|
351
|
+
return self._guard("pre_api_request", self._pre_api_request, kw)
|
|
352
|
+
|
|
353
|
+
def _attempt_start(self, kw: Mapping[str, Any]) -> float:
|
|
354
|
+
"""Start of this provider attempt, in epoch seconds.
|
|
355
|
+
|
|
356
|
+
Hermes sets ``started_at`` once per logical call, before its retry
|
|
357
|
+
loop, so every retry carries the first attempt's start. Only the first
|
|
358
|
+
attempt uses it; a retry starts when its own hook fires.
|
|
359
|
+
"""
|
|
360
|
+
started = kw.get("started_at")
|
|
361
|
+
retry = _int(kw.get("retry_count")) or 0
|
|
362
|
+
with self._lock:
|
|
363
|
+
earlier = self._attempted.get(_str(kw.get("api_request_id")), 0)
|
|
364
|
+
if retry == 0 and not earlier and isinstance(started, (int, float)) and started > 0:
|
|
365
|
+
return float(started)
|
|
366
|
+
return time.time()
|
|
367
|
+
|
|
368
|
+
def _pre_api_request(self, kw: Mapping[str, Any]) -> None:
|
|
369
|
+
model = _str(kw.get("model"))
|
|
370
|
+
key = self._llm_key(kw)
|
|
371
|
+
start = self._attempt_start(kw)
|
|
372
|
+
span = self._start_child(key, "chat {0}".format(model) if model else "chat",
|
|
373
|
+
kw, start_time=_ns(start))
|
|
374
|
+
with self._lock:
|
|
375
|
+
self._llm_start[key] = start
|
|
376
|
+
_set(span, {
|
|
377
|
+
SPAN_KIND: "LLM",
|
|
378
|
+
OPERATION: "chat",
|
|
379
|
+
"gen_ai.request.model": model,
|
|
380
|
+
"gen_ai.provider.name": _str(kw.get("provider")),
|
|
381
|
+
"server.address": _host(kw.get("base_url")),
|
|
382
|
+
"hermes.api_mode": _str(kw.get("api_mode")),
|
|
383
|
+
"hermes.api_request_id": _str(kw.get("api_request_id")),
|
|
384
|
+
"hermes.api_call_count": _int(kw.get("api_call_count")),
|
|
385
|
+
"hermes.retry_count": _int(kw.get("retry_count")),
|
|
386
|
+
"hermes.message_count": _int(kw.get("message_count")),
|
|
387
|
+
"hermes.tool_count": _int(kw.get("tool_count")),
|
|
388
|
+
"hermes.approx_input_tokens": _int(kw.get("approx_input_tokens")),
|
|
389
|
+
"hermes.request_char_count": _int(kw.get("request_char_count")),
|
|
390
|
+
"gen_ai.request.max_tokens": _int(kw.get("max_tokens")),
|
|
391
|
+
})
|
|
392
|
+
if self._capture:
|
|
393
|
+
self._content(span, INPUT_VALUE, INPUT_MIME, self._llm_input(kw))
|
|
394
|
+
|
|
395
|
+
def _llm_input(self, kw: Mapping[str, Any]) -> Any:
|
|
396
|
+
"""The latest messages of the request, newest kept when over the cap.
|
|
397
|
+
|
|
398
|
+
The head of the request body is the system prompt, which alone fills
|
|
399
|
+
the cap; it is reported by size only.
|
|
400
|
+
"""
|
|
401
|
+
request = kw.get("request")
|
|
402
|
+
body = request.get("body") if isinstance(request, Mapping) else None
|
|
403
|
+
messages = None
|
|
404
|
+
if isinstance(body, Mapping):
|
|
405
|
+
messages = body.get("messages") or body.get("input")
|
|
406
|
+
elif isinstance(request, Mapping):
|
|
407
|
+
messages = request.get("messages")
|
|
408
|
+
if not isinstance(messages, list):
|
|
409
|
+
messages = kw.get("request_messages")
|
|
410
|
+
if not isinstance(messages, list):
|
|
411
|
+
return None
|
|
412
|
+
system_chars = 0
|
|
413
|
+
rest = []
|
|
414
|
+
for message in messages:
|
|
415
|
+
if isinstance(message, Mapping) and message.get("role") in ("system", "developer"):
|
|
416
|
+
system_chars += len(_str(message.get("content")))
|
|
417
|
+
else:
|
|
418
|
+
rest.append(message)
|
|
419
|
+
if isinstance(body, Mapping) and isinstance(body.get("system"), (str, list)):
|
|
420
|
+
system_chars += len(_str(body.get("system")))
|
|
421
|
+
tail: List[Any] = []
|
|
422
|
+
budget = self._max_chars - 200
|
|
423
|
+
for message in reversed(rest):
|
|
424
|
+
size = len(json.dumps(message, ensure_ascii=False, default=str))
|
|
425
|
+
if tail and size > budget:
|
|
426
|
+
break
|
|
427
|
+
tail.insert(0, message)
|
|
428
|
+
budget -= size
|
|
429
|
+
return {"system_prompt_chars": system_chars, "message_count": len(messages),
|
|
430
|
+
"messages": tail}
|
|
431
|
+
|
|
432
|
+
def on_post_api_request(self, **kw: Any) -> None:
|
|
433
|
+
return self._guard("post_api_request", self._post_api_request, kw)
|
|
434
|
+
|
|
435
|
+
def _llm_span_to_end(self, kw: Mapping[str, Any]) -> Span:
|
|
436
|
+
"""The open LLM span for this request, or one started late if its pre hook never fired."""
|
|
437
|
+
key = self._llm_key(kw)
|
|
438
|
+
span = self._pop_child(key)
|
|
439
|
+
if span is not None:
|
|
440
|
+
return span
|
|
441
|
+
model = _str(kw.get("model"))
|
|
442
|
+
start = self._attempt_start(kw)
|
|
443
|
+
span = self._start_child(key, "chat {0}".format(model) if model else "chat", kw,
|
|
444
|
+
start_time=_ns(start))
|
|
445
|
+
self._pop_child(key)
|
|
446
|
+
with self._lock:
|
|
447
|
+
self._llm_start[key] = start
|
|
448
|
+
_set(span, {SPAN_KIND: "LLM", OPERATION: "chat", "gen_ai.request.model": model,
|
|
449
|
+
"gen_ai.provider.name": _str(kw.get("provider"))})
|
|
450
|
+
return span
|
|
451
|
+
|
|
452
|
+
def _post_api_request(self, kw: Mapping[str, Any]) -> None:
|
|
453
|
+
span = self._llm_span_to_end(kw)
|
|
454
|
+
usage = kw.get("usage") if isinstance(kw.get("usage"), Mapping) else {}
|
|
455
|
+
finish = _str(kw.get("finish_reason"))
|
|
456
|
+
_set(span, {
|
|
457
|
+
"gen_ai.response.model": _str(kw.get("response_model")),
|
|
458
|
+
"gen_ai.response.finish_reasons": [finish] if finish else None,
|
|
459
|
+
# Hermes' prompt_tokens already includes cache reads and writes.
|
|
460
|
+
"gen_ai.usage.input_tokens": _int(usage.get("prompt_tokens")),
|
|
461
|
+
"gen_ai.usage.output_tokens": _int(usage.get("output_tokens")),
|
|
462
|
+
"gen_ai.usage.total_tokens": _int(usage.get("total_tokens")),
|
|
463
|
+
"gen_ai.usage.cache_read_tokens": _int(usage.get("cache_read_tokens")),
|
|
464
|
+
"gen_ai.usage.cache_write_tokens": _int(usage.get("cache_write_tokens")),
|
|
465
|
+
"gen_ai.usage.output_tokens.reasoning": _int(usage.get("reasoning_tokens")),
|
|
466
|
+
"hermes.assistant_tool_call_count": _int(kw.get("assistant_tool_call_count")),
|
|
467
|
+
"hermes.assistant_content_chars": _int(kw.get("assistant_content_chars")),
|
|
468
|
+
})
|
|
469
|
+
start = self._finish_attempt(kw)
|
|
470
|
+
first_chunk = kw.get("first_chunk_at")
|
|
471
|
+
if isinstance(first_chunk, (int, float)) and start is not None:
|
|
472
|
+
span.set_attribute("hermes.time_to_first_chunk_ms",
|
|
473
|
+
round(max(0.0, first_chunk - start) * 1000))
|
|
474
|
+
if self._capture:
|
|
475
|
+
self._content(span, OUTPUT_VALUE, OUTPUT_MIME, _assistant_output(kw.get("assistant_message")))
|
|
476
|
+
span.set_status(Status(StatusCode.OK))
|
|
477
|
+
span.end(end_time=self._end_time(kw, start))
|
|
478
|
+
|
|
479
|
+
def _finish_attempt(self, kw: Mapping[str, Any]) -> Optional[float]:
|
|
480
|
+
"""Record that this request id had an attempt; return that attempt's start."""
|
|
481
|
+
key = self._llm_key(kw)
|
|
482
|
+
with self._lock:
|
|
483
|
+
request_id = _str(kw.get("api_request_id"))
|
|
484
|
+
self._attempted[request_id] = self._attempted.get(request_id, 0) + 1
|
|
485
|
+
return self._llm_start.pop(key, None)
|
|
486
|
+
|
|
487
|
+
@staticmethod
|
|
488
|
+
def _end_time(kw: Mapping[str, Any], start: Optional[float]) -> Optional[int]:
|
|
489
|
+
ended = kw.get("ended_at")
|
|
490
|
+
if isinstance(ended, (int, float)) and start is not None and ended >= start:
|
|
491
|
+
return _ns(ended)
|
|
492
|
+
# ended_at is measured from the logical call's start; for a retry that
|
|
493
|
+
# can be earlier than the attempt's own start. Use "now".
|
|
494
|
+
return None if not isinstance(ended, (int, float)) else time.time_ns()
|
|
495
|
+
|
|
496
|
+
def on_api_request_error(self, **kw: Any) -> None:
|
|
497
|
+
return self._guard("api_request_error", self._api_request_error, kw)
|
|
498
|
+
|
|
499
|
+
def _api_request_error(self, kw: Mapping[str, Any]) -> None:
|
|
500
|
+
span = self._llm_span_to_end(kw)
|
|
501
|
+
reason = _str(kw.get("reason"))
|
|
502
|
+
error = kw.get("error")
|
|
503
|
+
# Hermes sends error={"type": <class or category>, "message": <text>}.
|
|
504
|
+
error_type = _str(error.get("type")) if isinstance(error, Mapping) else (
|
|
505
|
+
type(error).__name__ if isinstance(error, BaseException) else "")
|
|
506
|
+
error_message = error.get("message") if isinstance(error, Mapping) else error
|
|
507
|
+
_set(span, {
|
|
508
|
+
"error.type": reason or error_type,
|
|
509
|
+
"hermes.error_class": error_type if reason else None,
|
|
510
|
+
"http.response.status_code": _int(kw.get("status_code")),
|
|
511
|
+
"hermes.retry_count": _int(kw.get("retry_count")),
|
|
512
|
+
"hermes.max_retries": _int(kw.get("max_retries")),
|
|
513
|
+
"hermes.retryable": kw.get("retryable") if isinstance(kw.get("retryable"), bool) else None,
|
|
514
|
+
})
|
|
515
|
+
# Provider error text can echo the prompt or local paths: it is content.
|
|
516
|
+
description = reason or error_type or "provider request failed"
|
|
517
|
+
if self._capture and error_message:
|
|
518
|
+
description = self._text(str(error_message)) or description
|
|
519
|
+
span.set_status(Status(StatusCode.ERROR, description))
|
|
520
|
+
start = self._finish_attempt(kw)
|
|
521
|
+
span.end(end_time=self._end_time(kw, start))
|
|
522
|
+
|
|
523
|
+
# --------------------------------------------------------------- tool calls
|
|
524
|
+
|
|
525
|
+
def _tool_key(self, kw: Mapping[str, Any]) -> str:
|
|
526
|
+
call_id = _str(kw.get("tool_call_id"))
|
|
527
|
+
if call_id:
|
|
528
|
+
return "tool:" + call_id
|
|
529
|
+
return "tool:{0}:{1}".format(self._turn_key(kw), _str(kw.get("tool_name")))
|
|
530
|
+
|
|
531
|
+
def on_post_tool_call(self, **kw: Any) -> None:
|
|
532
|
+
return self._guard("post_tool_call", self._post_tool_call, kw)
|
|
533
|
+
|
|
534
|
+
def _post_tool_call(self, kw: Mapping[str, Any]) -> None:
|
|
535
|
+
# Built from post_tool_call alone, timed by Hermes' duration_ms.
|
|
536
|
+
key = self._tool_key(kw)
|
|
537
|
+
duration = max(0, _int(kw.get("duration_ms")) or 0)
|
|
538
|
+
name = _str(kw.get("tool_name")) or "tool"
|
|
539
|
+
end = time.time_ns()
|
|
540
|
+
span = self._start_child(key, name, kw, start_time=end - duration * 1_000_000)
|
|
541
|
+
self._pop_child(key)
|
|
542
|
+
_set(span, {
|
|
543
|
+
SPAN_KIND: "TOOL",
|
|
544
|
+
OPERATION: "execute_tool",
|
|
545
|
+
"gen_ai.tool.name": name,
|
|
546
|
+
"gen_ai.tool.call.id": _str(kw.get("tool_call_id")),
|
|
547
|
+
"hermes.api_request_id": _str(kw.get("api_request_id")),
|
|
548
|
+
})
|
|
549
|
+
self._content(span, INPUT_VALUE, INPUT_MIME, kw.get("args"))
|
|
550
|
+
status = _str(kw.get("status"))
|
|
551
|
+
error_type = _str(kw.get("error_type"))
|
|
552
|
+
_set(span, {
|
|
553
|
+
"hermes.tool.status": status,
|
|
554
|
+
"hermes.tool.duration_ms": _int(kw.get("duration_ms")),
|
|
555
|
+
"error.type": error_type,
|
|
556
|
+
})
|
|
557
|
+
self._content(span, OUTPUT_VALUE, OUTPUT_MIME, kw.get("result"))
|
|
558
|
+
if status in _FAILED_TOOL_STATUSES or error_type:
|
|
559
|
+
description = error_type or status
|
|
560
|
+
if self._capture and kw.get("error_message"):
|
|
561
|
+
description = self._text(kw.get("error_message")) or description
|
|
562
|
+
span.set_status(Status(StatusCode.ERROR, description))
|
|
563
|
+
else:
|
|
564
|
+
span.set_status(Status(StatusCode.OK))
|
|
565
|
+
span.end(end_time=end)
|
|
566
|
+
|
|
567
|
+
# ----------------------------------------------------------------- shutdown
|
|
568
|
+
|
|
569
|
+
def end_all(self) -> None:
|
|
570
|
+
"""Close every open span (process exit); they are marked unfinished."""
|
|
571
|
+
with self._lock:
|
|
572
|
+
keys = list(self._turns)
|
|
573
|
+
for key in keys:
|
|
574
|
+
self._end_turn(key, attributes={"hermes.unfinished": True})
|
|
575
|
+
with self._lock:
|
|
576
|
+
leftovers = [entry[0] for entry in self._children.values()]
|
|
577
|
+
self._children.clear()
|
|
578
|
+
for span in leftovers:
|
|
579
|
+
span.set_attribute("hermes.unfinished", True)
|
|
580
|
+
span.end()
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _assistant_output(message: Any) -> Any:
|
|
584
|
+
"""Text, or the requested tool calls, from Hermes' normalized assistant message."""
|
|
585
|
+
if message is None:
|
|
586
|
+
return None
|
|
587
|
+
content = message.get("content") if isinstance(message, Mapping) else getattr(message, "content", None)
|
|
588
|
+
if isinstance(content, str) and content:
|
|
589
|
+
return content
|
|
590
|
+
calls = (message.get("tool_calls") if isinstance(message, Mapping)
|
|
591
|
+
else getattr(message, "tool_calls", None)) or []
|
|
592
|
+
out = []
|
|
593
|
+
for call in calls:
|
|
594
|
+
fn = call.get("function") if isinstance(call, Mapping) else getattr(call, "function", None)
|
|
595
|
+
name = fn.get("name") if isinstance(fn, Mapping) else getattr(fn, "name", None)
|
|
596
|
+
args = fn.get("arguments") if isinstance(fn, Mapping) else getattr(fn, "arguments", None)
|
|
597
|
+
out.append({"name": name, "arguments": args})
|
|
598
|
+
return out or content
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
HOOKS: Iterable[str] = (
|
|
602
|
+
"pre_llm_call", "post_llm_call", "pre_api_request", "post_api_request",
|
|
603
|
+
"api_request_error", "post_tool_call", "on_session_end",
|
|
604
|
+
"on_session_finalize", "subagent_start", "subagent_stop",
|
|
605
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0" # x-release-please-version
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: traceAI-hermes-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Future AGI tracing plugin for Hermes Agent: turns, model calls and tool calls as OpenTelemetry spans
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Keywords: opentelemetry,hermes-agent,nous-research,agent,tracing,observability,traceai,futureagi,llm
|
|
7
|
+
Author: Future AGI
|
|
8
|
+
Author-email: no-reply@futureagi.com
|
|
9
|
+
Requires-Python: >=3.11,<3.15
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: OpenTelemetry
|
|
12
|
+
Classifier: Framework :: OpenTelemetry :: Instrumentations
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: System :: Monitoring
|
|
24
|
+
Requires-Dist: opentelemetry-api (>=1.35.0)
|
|
25
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http (>=1.35.0)
|
|
26
|
+
Requires-Dist: opentelemetry-sdk (>=1.35.0)
|
|
27
|
+
Project-URL: Changelog, https://github.com/future-agi/traceAI/blob/dev/python/frameworks/hermes-agent/CHANGELOG.md
|
|
28
|
+
Project-URL: Documentation, https://docs.futureagi.com/docs/tracing
|
|
29
|
+
Project-URL: Homepage, https://futureagi.com
|
|
30
|
+
Project-URL: Issues, https://github.com/future-agi/traceAI/issues
|
|
31
|
+
Project-URL: Repository, https://github.com/future-agi/traceAI/tree/dev/python/frameworks/hermes-agent
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# traceAI-hermes-agent
|
|
35
|
+
|
|
36
|
+
A [Hermes Agent](https://hermes-agent.nousresearch.com/) plugin that exports
|
|
37
|
+
Hermes turns, model calls and tool calls to Future AGI as OpenTelemetry spans.
|
|
38
|
+
It registers observer callbacks on Hermes' documented plugin hooks; it is not
|
|
39
|
+
part of Hermes and does not change what Hermes does.
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
hermes.turn AGENT one user message to final answer (session, platform, outcome)
|
|
43
|
+
├── chat gpt-6-luna LLM one provider attempt: model, provider, tokens, finish reason
|
|
44
|
+
├── read_file TOOL one tool call: name, call id, duration, status
|
|
45
|
+
└── chat gpt-6-luna LLM
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
One trace per top-level Hermes turn. Every span carries `session.id` (the Hermes
|
|
49
|
+
session id), so the turns of a `hermes chat` session group into one Future AGI
|
|
50
|
+
session. A subagent's turn sits inside its parent turn's trace and carries the
|
|
51
|
+
subagent's own session id.
|
|
52
|
+
|
|
53
|
+
Alpha. Tested with Hermes Agent v2026.9.24 (0.21.5) on Python 3.12, in CLI
|
|
54
|
+
sessions against an OpenAI-compatible provider. Supports Python 3.11 to 3.14;
|
|
55
|
+
the test suite also passes on Python 3.14 against Hermes v0.22.0, which runs on 3.14.
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
The plugin is enabled per Hermes home and stays off until you enable it.
|
|
60
|
+
|
|
61
|
+
**Managed Hermes installs** (the `install.sh` installer, Hermes Desktop): install
|
|
62
|
+
the directory plugin in `hermes-plugin/futureagi/`. `hermes plugins install`
|
|
63
|
+
installs `traceAI-hermes-agent` from its `pyproject.toml` into Hermes'
|
|
64
|
+
environment (and `enable` retries it if that failed).
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
hermes plugins install future-agi/traceAI/python/frameworks/hermes-agent/hermes-plugin/futureagi
|
|
68
|
+
hermes plugins enable futureagi
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Hermes in a Python environment you manage** (a source checkout, a container
|
|
72
|
+
image): install the package into the environment Hermes runs in. It declares the
|
|
73
|
+
`hermes_agent.plugins` entry point `futureagi`.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install traceAI-hermes-agent
|
|
77
|
+
hermes plugins enable futureagi
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
To try an unreleased change, install from a traceAI checkout instead:
|
|
81
|
+
`pip install /path/to/traceAI/python/frameworks/hermes-agent`.
|
|
82
|
+
|
|
83
|
+
## Configure
|
|
84
|
+
|
|
85
|
+
Put the keys in `$HERMES_HOME/.env` (normally `~/.hermes/.env`) or in the
|
|
86
|
+
environment Hermes runs in:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
FI_API_KEY=your-futureagi-api-key
|
|
90
|
+
FI_SECRET_KEY=your-futureagi-secret-key
|
|
91
|
+
FI_PROJECT_NAME=hermes-agent
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
| Variable | Default | Meaning |
|
|
95
|
+
| --- | --- | --- |
|
|
96
|
+
| `FI_API_KEY`, `FI_SECRET_KEY` | none | Required. Without them the plugin logs one warning and does nothing. |
|
|
97
|
+
| `FI_BASE_URL` | `https://api.futureagi.com` | Spans go to `$FI_BASE_URL/tracer/v1/traces`. |
|
|
98
|
+
| `FI_PROJECT_NAME` | `hermes-agent` | Future AGI project. |
|
|
99
|
+
| `FI_HERMES_CAPTURE_CONTENT` | off | `true` also exports prompts, responses, tool arguments and results, and error text. |
|
|
100
|
+
| `FI_HERMES_MAX_CHARS` | `8000` | Per-attribute cap on captured text. |
|
|
101
|
+
|
|
102
|
+
In a multi-profile gateway (`gateway.multiplex_profiles`), each profile's turns
|
|
103
|
+
use that profile's own `.env` values, read through Hermes' profile secret scope.
|
|
104
|
+
A profile without keys is not traced; it never borrows another profile's keys.
|
|
105
|
+
|
|
106
|
+
Restart Hermes (and its gateway, if you run one) after enabling the plugin.
|
|
107
|
+
|
|
108
|
+
## What is captured
|
|
109
|
+
|
|
110
|
+
| Hermes hook | Span | Fields |
|
|
111
|
+
| --- | --- | --- |
|
|
112
|
+
| `pre_llm_call` | `hermes.turn` start | `session.id`, `hermes.turn_id`, `hermes.task_id`, `hermes.platform`, `hermes.model`, first-turn flag, parent session |
|
|
113
|
+
| `pre_api_request` | `chat <model>` start | `gen_ai.request.model`, `gen_ai.provider.name`, `server.address` (host only), API mode, attempt and retry counts, message and tool counts, approximate input tokens |
|
|
114
|
+
| `post_api_request` | `chat <model>` end | `gen_ai.response.model`, finish reason, `gen_ai.usage.input_tokens` (includes cache), `output_tokens`, `total_tokens`, cache read/write tokens, reasoning tokens, time to first chunk |
|
|
115
|
+
| `api_request_error` | `chat <model>`, status `ERROR` | Hermes' error category (`rate_limit`, `overloaded`, `timeout`, ...) as `error.type` and status, the error class as `hermes.error_class`, HTTP status, retryable flag |
|
|
116
|
+
| `post_tool_call` | span named after the tool | `gen_ai.tool.name`, `gen_ai.tool.call.id`, status, duration (the span is timed by Hermes' `duration_ms`); `ERROR` with Hermes' error type when the tool reports `error`, `blocked`, `timeout` or `cancelled` |
|
|
117
|
+
| `on_session_end` | `hermes.turn` end | completed / failed / interrupted, exit reason category; `ERROR` when the turn failed, otherwise `OK` |
|
|
118
|
+
| `subagent_start` | | parents the child session's turn under the parent turn |
|
|
119
|
+
| `on_session_finalize`, process exit | | closes spans that are still open (marked `hermes.unfinished`) and flushes |
|
|
120
|
+
|
|
121
|
+
Each provider attempt is its own span. The first attempt starts at the
|
|
122
|
+
`started_at` Hermes reports; a retry starts when its own `pre_api_request`
|
|
123
|
+
fires, so a failed attempt and its retry do not overlap.
|
|
124
|
+
|
|
125
|
+
The plugin does not register on `pre_tool_call`: Hermes treats that hook as a
|
|
126
|
+
fail-closed policy hook (a slow or failing callback blocks the tool), and its
|
|
127
|
+
callbacks never return a directive. A tool still running when Hermes exits
|
|
128
|
+
therefore has no span; a tool cancelled with Ctrl+C is reported, with status
|
|
129
|
+
`cancelled`.
|
|
130
|
+
|
|
131
|
+
## What is not captured
|
|
132
|
+
|
|
133
|
+
- Cost. The hooks carry token counts, not Hermes' cost estimate.
|
|
134
|
+
- Auxiliary model calls (titling, compression, vision, approvals) and streaming deltas.
|
|
135
|
+
- Gateway delivery, cron and kanban events, which are not agent turns.
|
|
136
|
+
- Gateway (messaging platform) sessions were not run in testing; they use the same hooks.
|
|
137
|
+
|
|
138
|
+
## Privacy
|
|
139
|
+
|
|
140
|
+
Always exported (metadata): session, turn and task ids, platform, model id,
|
|
141
|
+
provider name, the provider's host (from the base URL, without path or
|
|
142
|
+
credentials), tool names and call ids, token and message counts, durations,
|
|
143
|
+
statuses and Hermes' error categories. For a self-hosted provider the host can
|
|
144
|
+
be an internal hostname and the model id a local file name.
|
|
145
|
+
|
|
146
|
+
Not exported unless `FI_HERMES_CAPTURE_CONTENT=true`: the user message, the
|
|
147
|
+
assistant's answer, conversation history, the request sent to the model, tool
|
|
148
|
+
arguments, tool results, and all error text. Hermes puts exception text into
|
|
149
|
+
some turn exit reasons (`repeated_outer_errors(Error during API call #3: ...)`);
|
|
150
|
+
by default only the part before the parenthesis is exported.
|
|
151
|
+
|
|
152
|
+
With `FI_HERMES_CAPTURE_CONTENT=true`:
|
|
153
|
+
|
|
154
|
+
- the turn gets the user message as `input.value` and the final answer as `output.value`;
|
|
155
|
+
- each model call gets the latest messages of the request as input (newest kept
|
|
156
|
+
when over the cap; the system prompt is reported by size only, as
|
|
157
|
+
`system_prompt_chars`) and the assistant text or requested tool calls as output;
|
|
158
|
+
- each tool call gets its arguments and result; arguments can contain local paths;
|
|
159
|
+
- exit reasons and error statuses carry the full error text.
|
|
160
|
+
|
|
161
|
+
Each value is truncated to `FI_HERMES_MAX_CHARS`.
|
|
162
|
+
|
|
163
|
+
The plugin never reads Hermes' files: no session transcripts, `config.yaml`,
|
|
164
|
+
memory or skills. It installs no signal handlers, does not set the global
|
|
165
|
+
OpenTelemetry provider, and sends uncompressed OTLP/HTTP (the Future AGI
|
|
166
|
+
collector does not read `Content-Encoding`). When Future AGI is unreachable,
|
|
167
|
+
Hermes' exit and session teardown wait a few seconds for the export, then drop
|
|
168
|
+
those spans: about 3 seconds with current OpenTelemetry releases, at most about
|
|
169
|
+
6 with the oldest supported one (1.35).
|
|
170
|
+
|
|
171
|
+
## Not this plugin
|
|
172
|
+
|
|
173
|
+
- **Hermes' Langfuse plugin** sends to Langfuse's API, which Future AGI does not ingest.
|
|
174
|
+
- **Gateway health export** (`monitoring.export.otlp`) reports gateway health and delivery, not turns.
|
|
175
|
+
- **NeMo Relay file sinks** (ATOF / ATIF) write local files.
|
|
176
|
+
- **NeMo Relay's OpenTelemetry exporter**, which Hermes core drives, can also
|
|
177
|
+
send OTLP to Future AGI (a `plugins.toml` with `header_env` for the two keys and
|
|
178
|
+
`resource_attributes.project_name`). Tested with Hermes v2026.9.24 and
|
|
179
|
+
nemo-relay 0.8.4 (`gen_ai` projection): one trace per session with turn, model
|
|
180
|
+
and tool spans, but the model spans carry the full prompt and response
|
|
181
|
+
(`gen_ai.input.messages` / `gen_ai.output.messages`) even with
|
|
182
|
+
`enable_full_payloads = false`, no `session.id` is set, and the configuration is
|
|
183
|
+
process-wide, so every profile of a gateway exports to one project. This plugin
|
|
184
|
+
exists for a content-off default, Future AGI sessions and per-profile keys.
|
|
185
|
+
|
|
186
|
+
## Troubleshooting
|
|
187
|
+
|
|
188
|
+
- `hermes plugins list` must show `futureagi` as enabled in the Hermes home you use.
|
|
189
|
+
- The missing-keys warning and setup failures are logged by `traceai_hermes_agent`
|
|
190
|
+
to `$HERMES_HOME/logs/agent.log` and `errors.log`.
|
|
191
|
+
- A gateway loads plugins and `.env` at start: restart it after changes.
|
|
192
|
+
|
|
193
|
+
## Tests
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
pip install -e . pytest
|
|
197
|
+
pytest tests
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
With `hermes-agent` installed in the same environment, the contract tests also
|
|
201
|
+
check the hook names against `hermes_cli.plugins.VALID_HOOKS` and that Hermes
|
|
202
|
+
discovers the entry point.
|
|
203
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
traceai_hermes_agent/__init__.py,sha256=RDcr2GibZn1BYUditGYPDNzOzTyPogQ_NTSw5El_YJQ,9705
|
|
2
|
+
traceai_hermes_agent/_recorder.py,sha256=lrvMYGiUjX34HFQVWqp2F8dchno6yTpeuqRn1enLBPc,26534
|
|
3
|
+
traceai_hermes_agent/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
traceai_hermes_agent/version.py,sha256=IOEzhCbX916JIkvZr03GFY4bl9YqSkfjdqLO4qJ2FOs,50
|
|
5
|
+
traceai_hermes_agent-0.1.0.dist-info/METADATA,sha256=Q0mfDmyWknjSYgV4CNhzNoDJvQZcPSh-l8_mMVT9-9A,10521
|
|
6
|
+
traceai_hermes_agent-0.1.0.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
|
|
7
|
+
traceai_hermes_agent-0.1.0.dist-info/entry_points.txt,sha256=gjQ_GgkrpVOvhAxZy2oDsPuhFKGFQTfDbxZkBNIJwOc,64
|
|
8
|
+
traceai_hermes_agent-0.1.0.dist-info/RECORD,,
|