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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.5.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [hermes_agent.plugins]
2
+ futureagi=traceai_hermes_agent:register
3
+