Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,24 @@ def _print_nous_entitlement_guidance(agent, capability: str) -> bool:
return True


def _system_prompt_for_hooks(api_kwargs: Any, request_messages: Any) -> Any:
"""System prompt as actually sent to the provider, for observability hooks.

Providers move it out of ``messages``: Anthropic Messages uses a separate
``system`` kwarg (str or content-block list), the Responses/Codex API uses
top-level ``instructions``; Chat Completions keeps it as ``messages[0]``.
Returns None when the request carries no system prompt.
"""
system_prompt = api_kwargs.get("system")
if system_prompt is None:
system_prompt = api_kwargs.get("instructions")
if system_prompt is None and isinstance(request_messages, list) and request_messages:
first = request_messages[0]
if isinstance(first, dict) and first.get("role") == "system":
system_prompt = first.get("content")
return system_prompt


def _is_nous_inference_route(provider: str, base_url: str) -> bool:
provider = (provider or "").strip().lower()
if provider == "nous":
Expand Down Expand Up @@ -1214,6 +1232,13 @@ def run_conversation(
request_messages = api_kwargs.get("input")
if not isinstance(request_messages, list):
request_messages = api_messages
# Anthropic (``system``) and Responses/Codex
# (``instructions``) move the system prompt out of
# messages; pass it explicitly for observability
# plugins (Langfuse).
system_prompt_for_hooks = _system_prompt_for_hooks(
api_kwargs, request_messages
)
# Shallow-copy the outer list so plugins that retain the
# reference for async snapshotting don't observe later
# mutations of api_messages. The inner dicts are not
Expand Down Expand Up @@ -1248,6 +1273,7 @@ def run_conversation(
request_messages=list(request_messages)
if isinstance(request_messages, list)
else [],
system_prompt=system_prompt_for_hooks,
message_count=len(api_messages),
tool_count=len(agent.tools or []),
approx_input_tokens=approx_tokens,
Expand Down
4 changes: 4 additions & 0 deletions plugins/observability/langfuse/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ hermes plugins list # observability/langfuse should show "enable
hermes chat -q "hello" # then check Langfuse for a "Hermes turn" trace
```

Generation observations include the Hermes system prompt when the provider
uses a separate `system` param (Anthropic Messages API). Open an **LLM call**
child span to inspect `role: system` (truncated via `HERMES_LANGFUSE_MAX_CHARS`).

## Optional tuning

```bash
Expand Down
81 changes: 74 additions & 7 deletions plugins/observability/langfuse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,57 @@ def _coerce_request_messages(
return [{"role": "user", "content": user_message}]


def _serialize_system_prompt(system_prompt: Any) -> Optional[dict[str, Any]]:
"""Normalize Anthropic/Bedrock ``system`` param or OpenAI-style system content for Langfuse."""
if system_prompt is None:
return None
if isinstance(system_prompt, str):
text = system_prompt.strip()
if not text:
return None
return {"role": "system", "content": _safe_value(text)}
if isinstance(system_prompt, list):
parts: list[str] = []
for block in system_prompt:
if isinstance(block, dict):
# Anthropic blocks carry {"type": "text", "text": ...}; Bedrock
# Converse system blocks are {"text": ...} with no "type" key.
block_type = block.get("type")
if block_type == "text" or (block_type is None and "text" in block):
piece = block.get("text", "")
if isinstance(piece, str) and piece:
parts.append(piece)
elif isinstance(block, str) and block:
parts.append(block)
if not parts:
return None
return {"role": "system", "content": _safe_value("\n\n".join(parts))}
return None


def _messages_for_langfuse_input(
*,
request_messages: Any = None,
messages: Any = None,
conversation_history: Any = None,
user_message: Any = None,
system_prompt: Any = None,
) -> list[dict[str, Any]]:
"""Build generation input: include Anthropic ``system`` when split out of ``messages``."""
raw = _coerce_request_messages(
request_messages=request_messages,
messages=messages,
conversation_history=conversation_history,
user_message=user_message,
)
if raw and raw[0].get("role") == "system":
return _serialize_messages(raw)
system_msg = _serialize_system_prompt(system_prompt)
if system_msg is None:
return _serialize_messages(raw)
return [system_msg, *_serialize_messages(raw)]


def _serialize_messages(messages: Any) -> list[dict[str, Any]]:
if not isinstance(messages, list):
return []
Expand Down Expand Up @@ -845,6 +896,7 @@ def on_pre_llm_request(
user_message: Any = None,
turn_id: str = "",
api_request_id: str = "",
system_prompt: Any = None,
**_: Any,
) -> None:
client = _get_langfuse()
Expand All @@ -857,6 +909,16 @@ def on_pre_llm_request(
conversation_history=conversation_history,
user_message=user_message,
)
langfuse_input = _messages_for_langfuse_input(
request_messages=request_messages,
messages=messages,
conversation_history=conversation_history,
user_message=user_message,
system_prompt=system_prompt,
)
system_chars = 0
if langfuse_input and langfuse_input[0].get("role") == "system":
system_chars = len(str(langfuse_input[0].get("content") or ""))

task_key = _trace_key(
task_id,
Expand Down Expand Up @@ -888,18 +950,23 @@ def on_pre_llm_request(
previous = state.generations.pop(req_key, None)
if previous is not None:
_end_observation(previous)
gen_metadata = {
"provider": provider,
"platform": platform,
"api_mode": api_mode,
"base_url": base_url,
"message_count": message_count,
"approx_input_tokens": approx_input_tokens,
}
if system_chars:
gen_metadata["system_prompt_chars"] = system_chars
state.generations[req_key] = _start_child_observation(
state,
client=client,
name=f"LLM call {api_call_count}",
as_type="generation",
input_value=_serialize_messages(input_messages),
metadata={
"provider": provider,
"platform": platform,
"api_mode": api_mode,
"base_url": base_url,
},
input_value=langfuse_input,
metadata=gen_metadata,
model=model,
model_parameters={"api_mode": api_mode, "provider": provider},
)
Expand Down
Loading