trellar 0.3.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.
- trellar/__init__.py +15 -0
- trellar/_context.py +11 -0
- trellar/agent_loop.py +174 -0
- trellar/callbacks/__init__.py +0 -0
- trellar/callbacks/langchain_callback.py +689 -0
- trellar/settings.py +10 -0
- trellar-0.3.0.dist-info/METADATA +211 -0
- trellar-0.3.0.dist-info/RECORD +11 -0
- trellar-0.3.0.dist-info/WHEEL +5 -0
- trellar-0.3.0.dist-info/licenses/LICENSE +21 -0
- trellar-0.3.0.dist-info/top_level.txt +1 -0
trellar/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .agent_loop import (
|
|
2
|
+
AgentLoopResult,
|
|
3
|
+
NetworkHaltedError,
|
|
4
|
+
ObservabilityMode,
|
|
5
|
+
evaluate_confidence,
|
|
6
|
+
get_agent_guard,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"get_agent_guard",
|
|
11
|
+
"evaluate_confidence",
|
|
12
|
+
"AgentLoopResult",
|
|
13
|
+
"NetworkHaltedError",
|
|
14
|
+
"ObservabilityMode",
|
|
15
|
+
]
|
trellar/_context.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextvars import ContextVar
|
|
4
|
+
from typing import TYPE_CHECKING, Optional
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from .callbacks.langchain_callback import _AgentGuardCallback
|
|
8
|
+
|
|
9
|
+
_current_callback: ContextVar[Optional["_AgentGuardCallback"]] = ContextVar(
|
|
10
|
+
"_current_callback", default=None
|
|
11
|
+
)
|
trellar/agent_loop.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Optional, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from . import settings
|
|
11
|
+
from ._context import _current_callback
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .callbacks.langchain_callback import _AgentGuardCallback
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class AgentLoopResult:
|
|
21
|
+
explanation: str
|
|
22
|
+
score: int
|
|
23
|
+
decision_identifier: str
|
|
24
|
+
should_stop_network: bool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ObservabilityMode(str, Enum):
|
|
28
|
+
"""Controls whether the guard auto-triggers ``evaluate_confidence()`` when
|
|
29
|
+
the graph's root run finishes (i.e. ``graph.invoke()`` is about to return).
|
|
30
|
+
|
|
31
|
+
* ``ALWAYS`` — always auto-call at the end of the run.
|
|
32
|
+
* ``IF_NOT_EVALUATED`` — auto-call at the end only if
|
|
33
|
+
``evaluate_confidence()`` was not already successfully called anywhere
|
|
34
|
+
during the run.
|
|
35
|
+
* ``NONE`` — never auto-call (default; current behavior).
|
|
36
|
+
|
|
37
|
+
Errors raised by an auto-triggered call are caught and logged, never
|
|
38
|
+
propagated out of ``graph.invoke()``.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
ALWAYS = "always"
|
|
42
|
+
IF_NOT_EVALUATED = "if_not_evaluated"
|
|
43
|
+
NONE = "none"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class NetworkHaltedError(Exception):
|
|
47
|
+
"""Raised when the Trellar backend signals the agent network must stop."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, explanation: str, score: int, decision_identifier: str):
|
|
50
|
+
self.explanation = explanation
|
|
51
|
+
self.score = score
|
|
52
|
+
self.decision_identifier = decision_identifier
|
|
53
|
+
super().__init__(
|
|
54
|
+
f"Trellar halted the agent network (decision_identifier={decision_identifier}): {explanation}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_agent_guard(
|
|
59
|
+
agent_name: str,
|
|
60
|
+
observability_mode: ObservabilityMode = ObservabilityMode.NONE,
|
|
61
|
+
) -> "_AgentGuardCallback":
|
|
62
|
+
"""Create a callback handler that identifies this graph to the Trellar backend.
|
|
63
|
+
|
|
64
|
+
``agent_name`` must be a stable, unique name for this agent graph within
|
|
65
|
+
your repository (e.g. ``'research-agent'``, ``'support-bot'``). The backend
|
|
66
|
+
uses it to look up and maintain the graph's network profile across runs.
|
|
67
|
+
Different graphs in the same repo must use different names.
|
|
68
|
+
|
|
69
|
+
Usage::
|
|
70
|
+
|
|
71
|
+
guard = get_agent_guard("research-agent")
|
|
72
|
+
graph.invoke(input, config={"callbacks": [guard]})
|
|
73
|
+
result = evaluate_confidence()
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
agent_name: Unique, stable name for this agent graph.
|
|
77
|
+
observability_mode: Controls whether ``evaluate_confidence()`` is
|
|
78
|
+
auto-triggered when the graph run finishes. See
|
|
79
|
+
:class:`ObservabilityMode`. Defaults to ``ObservabilityMode.NONE``
|
|
80
|
+
(no auto-trigger, current behavior).
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
An internal callback handler bound to the given agent name.
|
|
84
|
+
"""
|
|
85
|
+
from .callbacks.langchain_callback import _AgentGuardCallback
|
|
86
|
+
return _AgentGuardCallback(agent_name=agent_name, observability_mode=observability_mode)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def evaluate_confidence(
|
|
90
|
+
*,
|
|
91
|
+
api_key: Optional[str] = None,
|
|
92
|
+
timeout: float = 30.0,
|
|
93
|
+
_observability_call: bool = False,
|
|
94
|
+
) -> AgentLoopResult:
|
|
95
|
+
"""Call the Trellar backend to get a confidence score.
|
|
96
|
+
|
|
97
|
+
``context``, ``trace_id``, and ``agent_name`` are all resolved automatically
|
|
98
|
+
from the active guard created by :func:`get_agent_guard` — no manual wiring needed::
|
|
99
|
+
|
|
100
|
+
guard = get_agent_guard("research-agent")
|
|
101
|
+
graph.invoke(input, config={"callbacks": [guard]})
|
|
102
|
+
result = evaluate_confidence()
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
api_key: Bearer token for authentication.
|
|
106
|
+
Defaults to the ``TRELLAR_API_KEY`` env var.
|
|
107
|
+
timeout: HTTP request timeout in seconds (default 30).
|
|
108
|
+
_observability_call: Internal — set by the guard's auto-trigger
|
|
109
|
+
(see ``ObservabilityMode``) to mark the request as
|
|
110
|
+
automatic rather than a manual call. Not for external use.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
:class:`AgentLoopResult` with ``explanation`` and ``score`` (1–10).
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
requests.HTTPError: On non-2xx responses.
|
|
117
|
+
ValueError: When the callback handler, trace_id, or api_key cannot be resolved.
|
|
118
|
+
NetworkHaltedError: When the backend signals that the agent network must stop.
|
|
119
|
+
"""
|
|
120
|
+
callback = _current_callback.get()
|
|
121
|
+
|
|
122
|
+
if callback is None:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
"No active callback handler found. Use get_agent_guard() to create one "
|
|
125
|
+
"and pass it to graph.invoke() before calling evaluate_confidence()."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if not callback.trace_id:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
"trace_id could not be resolved. Make sure get_agent_guard() is passed to "
|
|
131
|
+
"graph.invoke() before calling evaluate_confidence()."
|
|
132
|
+
)
|
|
133
|
+
resolved_trace_id = str(callback.trace_id)
|
|
134
|
+
|
|
135
|
+
base_url = settings.DEFAULT_ENDPOINT.rstrip("/")
|
|
136
|
+
key = api_key or settings.get_env_api_key()
|
|
137
|
+
if not key:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
"api_key must be provided or set via "
|
|
140
|
+
f"{settings.ENV_TRELLAR_API_KEY} environment variable."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
url = f"{base_url}/agent-gateway/v1/agent-loop"
|
|
144
|
+
headers = {
|
|
145
|
+
"Authorization": f"Bearer {key}",
|
|
146
|
+
"Content-Type": "application/json",
|
|
147
|
+
}
|
|
148
|
+
payload = {
|
|
149
|
+
"context": callback.events,
|
|
150
|
+
"trace_id": resolved_trace_id,
|
|
151
|
+
"agent_name": callback.agent_name,
|
|
152
|
+
"observability_call": _observability_call,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
response = requests.post(url, json=payload, headers=headers, timeout=timeout)
|
|
156
|
+
response.raise_for_status()
|
|
157
|
+
|
|
158
|
+
data = response.json()
|
|
159
|
+
result = AgentLoopResult(
|
|
160
|
+
explanation=data["explanation"],
|
|
161
|
+
score=data["score"],
|
|
162
|
+
decision_identifier=data["decision_identifier"],
|
|
163
|
+
should_stop_network=data["should_stop_network"],
|
|
164
|
+
)
|
|
165
|
+
callback._evaluated = True
|
|
166
|
+
|
|
167
|
+
if result.should_stop_network and not _observability_call :
|
|
168
|
+
raise NetworkHaltedError(
|
|
169
|
+
explanation=result.explanation,
|
|
170
|
+
score=result.score,
|
|
171
|
+
decision_identifier=result.decision_identifier,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
7
|
+
from langchain_core.outputs import LLMResult
|
|
8
|
+
|
|
9
|
+
from .._context import _current_callback
|
|
10
|
+
from ..agent_loop import ObservabilityMode
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _serialize_message(msg: Any) -> dict[str, Any]:
|
|
16
|
+
"""Serialize a LangChain BaseMessage to a plain dict (role + content + extras)."""
|
|
17
|
+
if not (hasattr(msg, "type") and hasattr(msg, "content")):
|
|
18
|
+
return {"raw": str(msg)}
|
|
19
|
+
|
|
20
|
+
result: dict[str, Any] = {"role": msg.type, "content": msg.content}
|
|
21
|
+
|
|
22
|
+
additional = getattr(msg, "additional_kwargs", {})
|
|
23
|
+
if additional:
|
|
24
|
+
# Capture tool_calls, function_call, etc.
|
|
25
|
+
result["additional_kwargs"] = additional
|
|
26
|
+
|
|
27
|
+
tool_calls = getattr(msg, "tool_calls", None)
|
|
28
|
+
if tool_calls:
|
|
29
|
+
result["tool_calls"] = tool_calls
|
|
30
|
+
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _content_to_str(content: Any) -> str:
|
|
35
|
+
"""Convert a message content value to a plain string.
|
|
36
|
+
|
|
37
|
+
LangChain message content can be a str, a list of dicts (multimodal),
|
|
38
|
+
or any other JSON-serializable value for structured outputs.
|
|
39
|
+
"""
|
|
40
|
+
if isinstance(content, str):
|
|
41
|
+
return content
|
|
42
|
+
try:
|
|
43
|
+
return json.dumps(content, ensure_ascii=False, default=str)
|
|
44
|
+
except Exception:
|
|
45
|
+
return str(content)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _extract_llm_input(messages: list[Any]) -> dict[str, Optional[str]]:
|
|
49
|
+
"""Extract structured system/human fields from a list of LangChain messages.
|
|
50
|
+
|
|
51
|
+
Returns a dict with:
|
|
52
|
+
- ``system``: content of the first SystemMessage, or ``None``
|
|
53
|
+
- ``human``: content of the last HumanMessage, or ``None``
|
|
54
|
+
|
|
55
|
+
For multi-turn conversation histories the *last* human turn is used as the
|
|
56
|
+
active prompt because that is what the LLM is responding to.
|
|
57
|
+
|
|
58
|
+
Handles both LangChain ``BaseMessage`` objects (standard) and plain dicts
|
|
59
|
+
(e.g. when Phoenix auto-instrumentation serialises messages before passing
|
|
60
|
+
them to the callback). Also accepts ``"user"`` as a synonym for ``"human"``
|
|
61
|
+
to cover OpenAI-style role names.
|
|
62
|
+
"""
|
|
63
|
+
system: Optional[str] = None
|
|
64
|
+
human: Optional[str] = None
|
|
65
|
+
|
|
66
|
+
for msg in messages:
|
|
67
|
+
if hasattr(msg, "type") and hasattr(msg, "content"):
|
|
68
|
+
# Standard LangChain BaseMessage object
|
|
69
|
+
role = str(msg.type).lower()
|
|
70
|
+
content = _content_to_str(msg.content)
|
|
71
|
+
elif isinstance(msg, dict):
|
|
72
|
+
# Serialised dict — may use "role" (OpenAI/Phoenix) or "type" (LangChain)
|
|
73
|
+
role = str(msg.get("role") or msg.get("type") or "").lower()
|
|
74
|
+
content = _content_to_str(msg.get("content") or "")
|
|
75
|
+
else:
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
if role == "system" and system is None:
|
|
79
|
+
system = content
|
|
80
|
+
elif role in ("human", "user"):
|
|
81
|
+
# "user" is the OpenAI/Phoenix style; keep overwriting so the last wins.
|
|
82
|
+
human = content
|
|
83
|
+
|
|
84
|
+
return {"system": system, "human": human}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _compact_json(value: Any) -> str:
|
|
88
|
+
"""Render *value* as compact JSON, falling back to repr on failure."""
|
|
89
|
+
try:
|
|
90
|
+
return json.dumps(value, ensure_ascii=False, default=str)
|
|
91
|
+
except Exception:
|
|
92
|
+
return repr(value)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _extract_model_name(serialized: dict[str, Any]) -> Optional[str]:
|
|
96
|
+
"""
|
|
97
|
+
Pull the real model identifier out of a serialized LLM dict.
|
|
98
|
+
|
|
99
|
+
LangChain puts the *class* name in ``serialized["name"]`` (e.g. "ChatOpenAI")
|
|
100
|
+
but the actual model string (e.g. "gpt-4o") lives inside ``kwargs``.
|
|
101
|
+
"""
|
|
102
|
+
kwargs = serialized.get("kwargs", {})
|
|
103
|
+
name = kwargs.get("model_name") or kwargs.get("model")
|
|
104
|
+
if not name:
|
|
105
|
+
# Fall back to the class name so we always have something.
|
|
106
|
+
name = serialized.get("name")
|
|
107
|
+
return name
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _AgentGuardCallback(BaseCallbackHandler):
|
|
111
|
+
"""Internal LangChain callback handler that tracks agent lifecycle events.
|
|
112
|
+
|
|
113
|
+
This class is not part of the public API. Use :func:`get_agent_guard` to
|
|
114
|
+
obtain an instance.
|
|
115
|
+
|
|
116
|
+
Accumulates all graph events into ``self.events`` as a list of dicts.
|
|
117
|
+
State is reset at the start of each top-level ``graph.invoke()`` call
|
|
118
|
+
(detected via ``parent_run_id is None``), so reusing one instance across
|
|
119
|
+
multiple invocations does not leak prior-run events into later payloads.
|
|
120
|
+
Each dict contains:
|
|
121
|
+
|
|
122
|
+
* ``event`` – callback name (e.g. ``on_chat_model_start``)
|
|
123
|
+
* ``graph_order`` – monotonically increasing step counter across the whole run
|
|
124
|
+
* ``trace_id`` – root run_id (graph-level trace)
|
|
125
|
+
* ``run_id`` – this specific run
|
|
126
|
+
* ``parent_run_id``– direct parent run (``None`` for the root)
|
|
127
|
+
* ``node_name`` – human-readable name of the node/chain/tool/model
|
|
128
|
+
* ``node_type`` – ``"llm"``, ``"tool"``, or ``"chain"``
|
|
129
|
+
* extra payload fields depending on the event type
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
# Maps the LangChain message `type` attribute to a human-readable prefix.
|
|
133
|
+
_MESSAGE_PREFIXES: dict[str, str] = {
|
|
134
|
+
"ai": "AI MESSAGE",
|
|
135
|
+
"human": "HUMAN MESSAGE",
|
|
136
|
+
"system": "SYSTEM MESSAGE",
|
|
137
|
+
"tool": "TOOL MESSAGE",
|
|
138
|
+
"function": "FUNCTION MESSAGE",
|
|
139
|
+
"chat": "CHAT MESSAGE",
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _serialize_message_obj(msg: Any) -> str:
|
|
144
|
+
"""Serialize a single LangChain message object into a labeled plain string.
|
|
145
|
+
|
|
146
|
+
Mirrors the logic in ``_serialize_messages`` but for a single value.
|
|
147
|
+
Falls back to ``str()`` for anything that is not a recognised message object.
|
|
148
|
+
"""
|
|
149
|
+
if hasattr(msg, "type") and hasattr(msg, "content"):
|
|
150
|
+
prefix = _AgentGuardCallback._MESSAGE_PREFIXES.get(msg.type.lower(), "MESSAGE")
|
|
151
|
+
content = msg.content
|
|
152
|
+
if not isinstance(content, str):
|
|
153
|
+
try:
|
|
154
|
+
content = json.dumps(content)
|
|
155
|
+
except (TypeError, ValueError):
|
|
156
|
+
content = str(content)
|
|
157
|
+
return f"{prefix}: {content}"
|
|
158
|
+
return str(msg)
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _serialize_messages(messages: list[Any]) -> list[str]:
|
|
162
|
+
"""Convert a list of LangChain message objects into labeled plain strings.
|
|
163
|
+
|
|
164
|
+
Each item is formatted as ``"<TYPE PREFIX>: <content>"`` so that the
|
|
165
|
+
result is JSON-serializable and unambiguous about which role produced
|
|
166
|
+
the content. Unknown message types fall back to ``"MESSAGE: ..."`` and
|
|
167
|
+
anything that is not a recognised message object is coerced via ``str()``.
|
|
168
|
+
"""
|
|
169
|
+
result: list[str] = []
|
|
170
|
+
for msg in messages:
|
|
171
|
+
if hasattr(msg, "type") and hasattr(msg, "content"):
|
|
172
|
+
prefix = _AgentGuardCallback._MESSAGE_PREFIXES.get(
|
|
173
|
+
msg.type.lower(), "MESSAGE"
|
|
174
|
+
)
|
|
175
|
+
content = msg.content
|
|
176
|
+
if not isinstance(content, str):
|
|
177
|
+
# content can be a list of dicts (e.g. multimodal messages)
|
|
178
|
+
try:
|
|
179
|
+
content = json.dumps(content)
|
|
180
|
+
except (TypeError, ValueError):
|
|
181
|
+
content = str(content)
|
|
182
|
+
result.append(f"{prefix}: {content}")
|
|
183
|
+
else:
|
|
184
|
+
result.append(str(msg))
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
def __init__(
|
|
188
|
+
self,
|
|
189
|
+
*,
|
|
190
|
+
agent_name: str,
|
|
191
|
+
observability_mode: ObservabilityMode = ObservabilityMode.NONE,
|
|
192
|
+
) -> None:
|
|
193
|
+
if not agent_name or not agent_name.strip():
|
|
194
|
+
raise ValueError(
|
|
195
|
+
"agent_name is required. It uniquely identifies this agent graph in the "
|
|
196
|
+
"Trellar backend and is used to track its network profile across runs. "
|
|
197
|
+
"Use a stable, descriptive name such as 'research-agent' or 'support-bot'."
|
|
198
|
+
)
|
|
199
|
+
super().__init__()
|
|
200
|
+
self.agent_name: str = agent_name
|
|
201
|
+
self.observability_mode: ObservabilityMode = ObservabilityMode(observability_mode)
|
|
202
|
+
self.trace_id: Optional[uuid.UUID] = None
|
|
203
|
+
self.events: list[dict[str, Any]] = []
|
|
204
|
+
self._step: int = 0
|
|
205
|
+
# Whether evaluate_confidence() has already succeeded during this run;
|
|
206
|
+
# reset per top-level invocation alongside the other run state below.
|
|
207
|
+
self._evaluated: bool = False
|
|
208
|
+
# run_id (str) -> {"name": str, "type": str}
|
|
209
|
+
self._run_registry: dict[str, dict[str, Any]] = {}
|
|
210
|
+
# LLM events that requested tool calls and are still awaiting the
|
|
211
|
+
# corresponding tool outputs. Each entry:
|
|
212
|
+
# {"event": <recorded event dict>, "parent_run_id": str | None,
|
|
213
|
+
# "remaining_tools": [tool names...]}
|
|
214
|
+
self._pending_llm_tool_calls: list[dict[str, Any]] = []
|
|
215
|
+
|
|
216
|
+
# ------------------------------------------------------------------
|
|
217
|
+
# Internal helpers
|
|
218
|
+
# ------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def _next_step(self) -> int:
|
|
221
|
+
self._step += 1
|
|
222
|
+
return self._step
|
|
223
|
+
|
|
224
|
+
def _register(
|
|
225
|
+
self,
|
|
226
|
+
run_id: uuid.UUID,
|
|
227
|
+
name: Optional[str],
|
|
228
|
+
node_type: str,
|
|
229
|
+
) -> None:
|
|
230
|
+
self._run_registry[str(run_id)] = {"name": name, "type": node_type}
|
|
231
|
+
|
|
232
|
+
def _record(
|
|
233
|
+
self,
|
|
234
|
+
event: str,
|
|
235
|
+
run_id: uuid.UUID,
|
|
236
|
+
parent_run_id: Optional[uuid.UUID] = None,
|
|
237
|
+
**data: Any,
|
|
238
|
+
) -> None:
|
|
239
|
+
node_info = self._run_registry.get(str(run_id), {})
|
|
240
|
+
self.events.append(
|
|
241
|
+
self._to_jsonable(
|
|
242
|
+
{
|
|
243
|
+
"event": event,
|
|
244
|
+
"graph_order": self._next_step(),
|
|
245
|
+
"trace_id": str(self.trace_id),
|
|
246
|
+
"run_id": str(run_id),
|
|
247
|
+
"parent_run_id": str(parent_run_id) if parent_run_id else None,
|
|
248
|
+
"node_name": node_info.get("name"),
|
|
249
|
+
"node_type": node_info.get("type"),
|
|
250
|
+
**data,
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
@staticmethod
|
|
256
|
+
def _to_jsonable(value: Any) -> Any:
|
|
257
|
+
"""Recursively coerce *value* into something ``json.dumps`` can handle.
|
|
258
|
+
|
|
259
|
+
LangChain/LangGraph hand callbacks all sorts of raw objects that are
|
|
260
|
+
not JSON-safe out of the box — most notably pydantic models (e.g. the
|
|
261
|
+
return value of ``with_structured_output``) and message objects. This
|
|
262
|
+
makes sure nothing appended to ``self.events`` can ever break the
|
|
263
|
+
``evaluate_confidence()`` HTTP call downstream.
|
|
264
|
+
"""
|
|
265
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
266
|
+
return value
|
|
267
|
+
if hasattr(value, "type") and hasattr(value, "content"):
|
|
268
|
+
return _AgentGuardCallback._serialize_message_obj(value)
|
|
269
|
+
if hasattr(value, "model_dump"):
|
|
270
|
+
return _AgentGuardCallback._to_jsonable(value.model_dump(mode="json"))
|
|
271
|
+
if isinstance(value, dict):
|
|
272
|
+
return {k: _AgentGuardCallback._to_jsonable(v) for k, v in value.items()}
|
|
273
|
+
if isinstance(value, (list, tuple)):
|
|
274
|
+
return [_AgentGuardCallback._to_jsonable(v) for v in value]
|
|
275
|
+
try:
|
|
276
|
+
json.dumps(value)
|
|
277
|
+
return value
|
|
278
|
+
except (TypeError, ValueError):
|
|
279
|
+
return str(value)
|
|
280
|
+
|
|
281
|
+
# ------------------------------------------------------------------
|
|
282
|
+
# LLM events
|
|
283
|
+
# ------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
def on_llm_start(
|
|
286
|
+
self,
|
|
287
|
+
serialized: dict[str, Any],
|
|
288
|
+
prompts: list[str],
|
|
289
|
+
*,
|
|
290
|
+
run_id: uuid.UUID,
|
|
291
|
+
parent_run_id: Optional[uuid.UUID] = None,
|
|
292
|
+
**kwargs: Any,
|
|
293
|
+
) -> None:
|
|
294
|
+
model = _extract_model_name(serialized)
|
|
295
|
+
self._register(run_id, model, "llm")
|
|
296
|
+
self._record(
|
|
297
|
+
"on_llm_start",
|
|
298
|
+
run_id,
|
|
299
|
+
parent_run_id,
|
|
300
|
+
model=model,
|
|
301
|
+
input={"system": None, "human": "\n".join(prompts)},
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def on_chat_model_start(
|
|
305
|
+
self,
|
|
306
|
+
serialized: dict[str, Any],
|
|
307
|
+
messages: list[list[Any]],
|
|
308
|
+
*,
|
|
309
|
+
run_id: uuid.UUID,
|
|
310
|
+
parent_run_id: Optional[uuid.UUID] = None,
|
|
311
|
+
**kwargs: Any,
|
|
312
|
+
) -> None:
|
|
313
|
+
model = _extract_model_name(serialized)
|
|
314
|
+
self._register(run_id, model, "llm")
|
|
315
|
+
|
|
316
|
+
# messages is list[list[BaseMessage]] — one inner list per prompt batch item.
|
|
317
|
+
# Use the first batch to extract system/human fields.
|
|
318
|
+
llm_input = _extract_llm_input(messages[0] if messages else [])
|
|
319
|
+
self._record(
|
|
320
|
+
"on_chat_model_start",
|
|
321
|
+
run_id,
|
|
322
|
+
parent_run_id,
|
|
323
|
+
model=model,
|
|
324
|
+
input=llm_input,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def on_llm_end(
|
|
328
|
+
self,
|
|
329
|
+
response: LLMResult,
|
|
330
|
+
*,
|
|
331
|
+
run_id: uuid.UUID,
|
|
332
|
+
parent_run_id: Optional[uuid.UUID] = None,
|
|
333
|
+
**kwargs: Any,
|
|
334
|
+
) -> None:
|
|
335
|
+
# Pull the text from the first generation of the first batch.
|
|
336
|
+
# Try multiple attributes in priority order to handle:
|
|
337
|
+
# - ChatGeneration (.message.content) — standard LangChain chat models
|
|
338
|
+
# - Generation (.text) — plain (non-chat) LLMs
|
|
339
|
+
# - Gemini multimodal content (list of parts — serialise to JSON)
|
|
340
|
+
# - Dicts produced by Phoenix instrumentation wrapping
|
|
341
|
+
response_text: str = ""
|
|
342
|
+
tool_calls: list[Any] = []
|
|
343
|
+
if response.generations:
|
|
344
|
+
first_batch = response.generations[0]
|
|
345
|
+
if first_batch:
|
|
346
|
+
gen = first_batch[0]
|
|
347
|
+
|
|
348
|
+
# 1. Try .message.content (ChatGeneration)
|
|
349
|
+
message = getattr(gen, "message", None)
|
|
350
|
+
if message is not None:
|
|
351
|
+
tool_calls = getattr(message, "tool_calls", None) or []
|
|
352
|
+
content = getattr(message, "content", None)
|
|
353
|
+
if content is not None:
|
|
354
|
+
response_text = _content_to_str(content)
|
|
355
|
+
else:
|
|
356
|
+
# Content is None — fall through to .text
|
|
357
|
+
message = None
|
|
358
|
+
|
|
359
|
+
# 2. Try .text (plain Generation or ChatGeneration fallback)
|
|
360
|
+
if not message:
|
|
361
|
+
raw_text = getattr(gen, "text", None)
|
|
362
|
+
if raw_text is not None:
|
|
363
|
+
# .text can be a list when Gemini returns multimodal content
|
|
364
|
+
response_text = _content_to_str(raw_text) if not isinstance(raw_text, str) else (raw_text or "")
|
|
365
|
+
|
|
366
|
+
# 3. Treat gen itself as a dict (Phoenix serialisation edge case)
|
|
367
|
+
if not response_text and isinstance(gen, dict):
|
|
368
|
+
msg_dict = gen.get("message") or {}
|
|
369
|
+
if isinstance(msg_dict, dict):
|
|
370
|
+
response_text = _content_to_str(msg_dict.get("content"))
|
|
371
|
+
if not tool_calls:
|
|
372
|
+
tool_calls = msg_dict.get("tool_calls") or []
|
|
373
|
+
else:
|
|
374
|
+
response_text = _content_to_str(gen.get("text", ""))
|
|
375
|
+
|
|
376
|
+
token_usage = (response.llm_output or {}).get("token_usage") or (
|
|
377
|
+
response.llm_output or {}
|
|
378
|
+
).get("usage")
|
|
379
|
+
|
|
380
|
+
# Fold tool calls into the response text so the payload keeps its
|
|
381
|
+
# original shape ({"response": <str>}). The matching tool results are
|
|
382
|
+
# appended retroactively by on_tool_end once each tool finishes.
|
|
383
|
+
if tool_calls:
|
|
384
|
+
parts = [response_text] if response_text else []
|
|
385
|
+
for tc in tool_calls:
|
|
386
|
+
if isinstance(tc, dict):
|
|
387
|
+
parts.append(
|
|
388
|
+
f"TOOL CALL: {tc.get('name')}(args={_compact_json(tc.get('args'))})"
|
|
389
|
+
)
|
|
390
|
+
response_text = "\n".join(parts)
|
|
391
|
+
|
|
392
|
+
self._record(
|
|
393
|
+
"on_llm_end",
|
|
394
|
+
run_id,
|
|
395
|
+
parent_run_id,
|
|
396
|
+
output={"response": response_text},
|
|
397
|
+
token_usage=token_usage,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if tool_calls:
|
|
401
|
+
# _record appends a jsonable copy; keep a reference to that copy so
|
|
402
|
+
# on_tool_end can enrich it in place before the payload is built.
|
|
403
|
+
self._pending_llm_tool_calls.append(
|
|
404
|
+
{
|
|
405
|
+
"event": self.events[-1],
|
|
406
|
+
"parent_run_id": str(parent_run_id) if parent_run_id else None,
|
|
407
|
+
"remaining_tools": [
|
|
408
|
+
tc.get("name") for tc in tool_calls if isinstance(tc, dict)
|
|
409
|
+
],
|
|
410
|
+
}
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
def on_llm_error(
|
|
414
|
+
self,
|
|
415
|
+
error: BaseException,
|
|
416
|
+
*,
|
|
417
|
+
run_id: uuid.UUID,
|
|
418
|
+
parent_run_id: Optional[uuid.UUID] = None,
|
|
419
|
+
**kwargs: Any,
|
|
420
|
+
) -> None:
|
|
421
|
+
self._record("on_llm_error", run_id, parent_run_id, error=str(error))
|
|
422
|
+
|
|
423
|
+
# ------------------------------------------------------------------
|
|
424
|
+
# Tool events
|
|
425
|
+
# ------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
def on_tool_start(
|
|
428
|
+
self,
|
|
429
|
+
serialized: dict[str, Any],
|
|
430
|
+
input_str: str,
|
|
431
|
+
*,
|
|
432
|
+
run_id: uuid.UUID,
|
|
433
|
+
parent_run_id: Optional[uuid.UUID] = None,
|
|
434
|
+
**kwargs: Any,
|
|
435
|
+
) -> None:
|
|
436
|
+
tool_name = serialized.get("name")
|
|
437
|
+
tool_description = serialized.get("description")
|
|
438
|
+
self._register(run_id, tool_name, "tool")
|
|
439
|
+
|
|
440
|
+
# ``inputs`` kwarg carries the parsed argument dict when available.
|
|
441
|
+
parsed_inputs = kwargs.get("inputs")
|
|
442
|
+
|
|
443
|
+
self._record(
|
|
444
|
+
"on_tool_start",
|
|
445
|
+
run_id,
|
|
446
|
+
parent_run_id,
|
|
447
|
+
tool=tool_name,
|
|
448
|
+
tool_description=tool_description,
|
|
449
|
+
input={
|
|
450
|
+
"raw": input_str,
|
|
451
|
+
"parsed": parsed_inputs,
|
|
452
|
+
},
|
|
453
|
+
# parent_run_id already in the record; surface it explicitly
|
|
454
|
+
# so callers can link this tool call back to the LLM that invoked it.
|
|
455
|
+
invoked_by_run_id=str(parent_run_id) if parent_run_id else None,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
def on_tool_end(
|
|
459
|
+
self,
|
|
460
|
+
output: Any,
|
|
461
|
+
*,
|
|
462
|
+
run_id: uuid.UUID,
|
|
463
|
+
parent_run_id: Optional[uuid.UUID] = None,
|
|
464
|
+
**kwargs: Any,
|
|
465
|
+
) -> None:
|
|
466
|
+
serialized_output = self._serialize_message_obj(output)
|
|
467
|
+
self._record("on_tool_end", run_id, parent_run_id, output=serialized_output)
|
|
468
|
+
tool_name = self._run_registry.get(str(run_id), {}).get("name")
|
|
469
|
+
self._attach_tool_response_to_llm(tool_name, serialized_output, parent_run_id)
|
|
470
|
+
|
|
471
|
+
def _attach_tool_response_to_llm(
|
|
472
|
+
self,
|
|
473
|
+
tool_name: Optional[str],
|
|
474
|
+
serialized_output: str,
|
|
475
|
+
parent_run_id: Optional[uuid.UUID],
|
|
476
|
+
) -> None:
|
|
477
|
+
"""Retroactively attach a finished tool's output to the LLM event that
|
|
478
|
+
requested it, so the LLM's recorded output is never just an empty string.
|
|
479
|
+
|
|
480
|
+
Matching strategy (most recent entries first):
|
|
481
|
+
1. A pending LLM event sharing the same ``parent_run_id`` (the LLM and
|
|
482
|
+
the tool live in the same chain/node — direct-invocation pattern).
|
|
483
|
+
2. Any pending LLM event still awaiting this tool name (covers
|
|
484
|
+
ToolNode/react-agent graphs where the tool runs under a different
|
|
485
|
+
parent chain).
|
|
486
|
+
"""
|
|
487
|
+
if not tool_name or not self._pending_llm_tool_calls:
|
|
488
|
+
return
|
|
489
|
+
|
|
490
|
+
parent_id = str(parent_run_id) if parent_run_id else None
|
|
491
|
+
match: Optional[dict[str, Any]] = None
|
|
492
|
+
for entry in reversed(self._pending_llm_tool_calls):
|
|
493
|
+
if tool_name not in entry["remaining_tools"]:
|
|
494
|
+
continue
|
|
495
|
+
if entry["parent_run_id"] == parent_id:
|
|
496
|
+
match = entry
|
|
497
|
+
break
|
|
498
|
+
if match is None:
|
|
499
|
+
match = entry
|
|
500
|
+
|
|
501
|
+
if match is None:
|
|
502
|
+
return
|
|
503
|
+
|
|
504
|
+
match["event"]["output"]["response"] += (
|
|
505
|
+
f"\nTOOL RESPONSE [{tool_name}]: {serialized_output}"
|
|
506
|
+
)
|
|
507
|
+
match["remaining_tools"].remove(tool_name)
|
|
508
|
+
if not match["remaining_tools"]:
|
|
509
|
+
self._pending_llm_tool_calls.remove(match)
|
|
510
|
+
|
|
511
|
+
def on_tool_error(
|
|
512
|
+
self,
|
|
513
|
+
error: BaseException,
|
|
514
|
+
*,
|
|
515
|
+
run_id: uuid.UUID,
|
|
516
|
+
parent_run_id: Optional[uuid.UUID] = None,
|
|
517
|
+
**kwargs: Any,
|
|
518
|
+
) -> None:
|
|
519
|
+
self._record("on_tool_error", run_id, parent_run_id, error=str(error))
|
|
520
|
+
|
|
521
|
+
# ------------------------------------------------------------------
|
|
522
|
+
# Chain / Graph node events
|
|
523
|
+
# ------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
def on_chain_start(
|
|
526
|
+
self,
|
|
527
|
+
serialized: dict[str, Any],
|
|
528
|
+
inputs: dict[str, Any],
|
|
529
|
+
*,
|
|
530
|
+
run_id: uuid.UUID,
|
|
531
|
+
parent_run_id: Optional[uuid.UUID] = None,
|
|
532
|
+
**kwargs: Any,
|
|
533
|
+
) -> None:
|
|
534
|
+
if parent_run_id is None:
|
|
535
|
+
# Root invocation — this run_id is the graph-level trace ID.
|
|
536
|
+
self.trace_id = run_id
|
|
537
|
+
# Guard against reused instances: if this handler is passed into more
|
|
538
|
+
# than one top-level graph.invoke() (sequentially), drop state from
|
|
539
|
+
# the previous run instead of letting it accumulate unbounded and
|
|
540
|
+
# leaking into this run's evaluate_confidence() payload.
|
|
541
|
+
self.events = []
|
|
542
|
+
self._step = 0
|
|
543
|
+
self._run_registry = {}
|
|
544
|
+
self._pending_llm_tool_calls = []
|
|
545
|
+
self._evaluated = False
|
|
546
|
+
# Self-register so evaluate_confidence() can pick us up automatically.
|
|
547
|
+
_current_callback.set(self)
|
|
548
|
+
|
|
549
|
+
chain_name = (serialized or {}).get("name") or kwargs.get("name")
|
|
550
|
+
|
|
551
|
+
# `inputs` is usually the graph/node state dict, but LangChain also
|
|
552
|
+
# fires this event for internal sub-runnables (e.g. ToolNode) whose
|
|
553
|
+
# raw input is a bare list of messages or a single message object.
|
|
554
|
+
# The backend's schema requires `inputs` to always be a list, so
|
|
555
|
+
# every branch below normalizes to one instead of a bare string/dict.
|
|
556
|
+
if isinstance(inputs, dict) and "messages" in inputs:
|
|
557
|
+
safe_inputs = self._serialize_messages(inputs["messages"])
|
|
558
|
+
elif isinstance(inputs, list):
|
|
559
|
+
safe_inputs = self._serialize_messages(inputs)
|
|
560
|
+
else:
|
|
561
|
+
safe_inputs = [self._to_jsonable(inputs)]
|
|
562
|
+
|
|
563
|
+
# LangGraph stamps its own superstep number onto the RunnableConfig
|
|
564
|
+
# metadata for each Pregel node task (see langgraph/pregel/_algo.py).
|
|
565
|
+
# Nodes sharing the same langgraph_step ran in the same superstep —
|
|
566
|
+
# i.e. in parallel — which lets the backend distinguish true fan-out
|
|
567
|
+
# branches from a sequential chain. None for non-LangGraph callers.
|
|
568
|
+
metadata = kwargs.get("metadata") or {}
|
|
569
|
+
langgraph_step = metadata.get("langgraph_step")
|
|
570
|
+
|
|
571
|
+
self._register(run_id, chain_name, "chain")
|
|
572
|
+
self._record(
|
|
573
|
+
"on_chain_start",
|
|
574
|
+
run_id,
|
|
575
|
+
parent_run_id,
|
|
576
|
+
inputs=safe_inputs,
|
|
577
|
+
langgraph_step=langgraph_step,
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
def on_chain_end(
|
|
581
|
+
self,
|
|
582
|
+
outputs: dict[str, Any],
|
|
583
|
+
*,
|
|
584
|
+
run_id: uuid.UUID,
|
|
585
|
+
parent_run_id: Optional[uuid.UUID] = None,
|
|
586
|
+
**kwargs: Any,
|
|
587
|
+
) -> None:
|
|
588
|
+
# See on_chain_start — `outputs` is not always a dict (e.g. the raw
|
|
589
|
+
# pydantic model returned by a `with_structured_output` sub-chain).
|
|
590
|
+
if isinstance(outputs, dict) and "messages" in outputs:
|
|
591
|
+
outputs = {**outputs, "messages": self._serialize_messages(outputs["messages"])}
|
|
592
|
+
self._record("on_chain_end", run_id, parent_run_id, outputs=self._to_jsonable(outputs))
|
|
593
|
+
# Do NOT clear _current_callback here. ContextVar is already scoped per
|
|
594
|
+
# asyncio Task / thread, so it never leaks across concurrent runs.
|
|
595
|
+
# Clearing it before graph.invoke() returns would make evaluate_confidence()
|
|
596
|
+
# fail when called after the graph completes.
|
|
597
|
+
if parent_run_id is None:
|
|
598
|
+
# Root run ending — the whole graph flow has reached its end.
|
|
599
|
+
self._maybe_auto_evaluate()
|
|
600
|
+
|
|
601
|
+
def _maybe_auto_evaluate(self) -> None:
|
|
602
|
+
"""Auto-trigger evaluate_confidence() per self.observability_mode.
|
|
603
|
+
|
|
604
|
+
Errors are caught and logged, never raised, so a passive observability
|
|
605
|
+
call can never crash the graph.
|
|
606
|
+
"""
|
|
607
|
+
if self.observability_mode is ObservabilityMode.NONE:
|
|
608
|
+
return
|
|
609
|
+
if self.observability_mode is ObservabilityMode.IF_NOT_EVALUATED and self._evaluated:
|
|
610
|
+
return
|
|
611
|
+
from ..agent_loop import evaluate_confidence
|
|
612
|
+
try:
|
|
613
|
+
evaluate_confidence(_observability_call=True)
|
|
614
|
+
except Exception:
|
|
615
|
+
logger.warning("Auto-triggered evaluate_confidence() failed", exc_info=True)
|
|
616
|
+
|
|
617
|
+
def on_chain_error(
|
|
618
|
+
self,
|
|
619
|
+
error: BaseException,
|
|
620
|
+
*,
|
|
621
|
+
run_id: uuid.UUID,
|
|
622
|
+
parent_run_id: Optional[uuid.UUID] = None,
|
|
623
|
+
**kwargs: Any,
|
|
624
|
+
) -> None:
|
|
625
|
+
self._record("on_chain_error", run_id, parent_run_id, error=str(error))
|
|
626
|
+
|
|
627
|
+
# ------------------------------------------------------------------
|
|
628
|
+
# Context serialization
|
|
629
|
+
# ------------------------------------------------------------------
|
|
630
|
+
|
|
631
|
+
def build_context(self) -> str:
|
|
632
|
+
"""Serialize collected events into a structured string for the Trellar backend.
|
|
633
|
+
|
|
634
|
+
Produces a numbered, step-by-step narrative of the full agent run
|
|
635
|
+
(LLM calls, tool invocations, chain boundaries) suitable as the
|
|
636
|
+
``context`` field of the evaluate_confidence request.
|
|
637
|
+
"""
|
|
638
|
+
lines: list[str] = [
|
|
639
|
+
f"=== Agent Run Context ===",
|
|
640
|
+
f"Trace ID: {self.trace_id}",
|
|
641
|
+
f"Total steps: {len(self.events)}",
|
|
642
|
+
"",
|
|
643
|
+
]
|
|
644
|
+
|
|
645
|
+
for event in self.events:
|
|
646
|
+
step = event.get("graph_order", "?")
|
|
647
|
+
event_name = event.get("event", "unknown")
|
|
648
|
+
node_name = event.get("node_name") or ""
|
|
649
|
+
node_type = event.get("node_type") or ""
|
|
650
|
+
|
|
651
|
+
header = f"[Step {step}] {event_name}"
|
|
652
|
+
if node_name:
|
|
653
|
+
header += f" ({node_type}: {node_name})"
|
|
654
|
+
lines.append(header)
|
|
655
|
+
|
|
656
|
+
# Per-event payload rendering
|
|
657
|
+
if event_name in ("on_llm_start", "on_chat_model_start"):
|
|
658
|
+
inp = event.get("input", {})
|
|
659
|
+
if inp.get("system"):
|
|
660
|
+
lines.append(f" system: {inp['system']}")
|
|
661
|
+
if inp.get("human"):
|
|
662
|
+
lines.append(f" human: {inp['human']}")
|
|
663
|
+
|
|
664
|
+
elif event_name == "on_llm_end":
|
|
665
|
+
out = event.get("output", {})
|
|
666
|
+
usage = event.get("token_usage")
|
|
667
|
+
lines.append(f" response: {out.get('response', '')}")
|
|
668
|
+
if usage:
|
|
669
|
+
lines.append(f" token_usage: {_compact_json(usage)}")
|
|
670
|
+
|
|
671
|
+
elif event_name == "on_tool_start":
|
|
672
|
+
lines.append(f" tool: {event.get('tool', '')}")
|
|
673
|
+
lines.append(f" input: {_compact_json(event.get('input', {}))}")
|
|
674
|
+
|
|
675
|
+
elif event_name == "on_tool_end":
|
|
676
|
+
lines.append(f" output: {_compact_json(event.get('output', ''))}")
|
|
677
|
+
|
|
678
|
+
elif event_name == "on_chain_start":
|
|
679
|
+
lines.append(f" inputs: {_compact_json(event.get('inputs', {}))}")
|
|
680
|
+
|
|
681
|
+
elif event_name == "on_chain_end":
|
|
682
|
+
lines.append(f" outputs: {_compact_json(event.get('outputs', {}))}")
|
|
683
|
+
|
|
684
|
+
elif event_name in ("on_llm_error", "on_tool_error", "on_chain_error"):
|
|
685
|
+
lines.append(f" error: {event.get('error', '')}")
|
|
686
|
+
|
|
687
|
+
lines.append("") # blank line between steps
|
|
688
|
+
|
|
689
|
+
return "\n".join(lines)
|
trellar/settings.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
ENV_TRELLAR_API_KEY = "TRELLAR_API_KEY"
|
|
5
|
+
|
|
6
|
+
DEFAULT_ENDPOINT = "https://api.trellar.io/"
|
|
7
|
+
DEFAULT_ENDPOINT = "http://localhost:8001"
|
|
8
|
+
|
|
9
|
+
def get_env_api_key() -> Optional[str]:
|
|
10
|
+
return os.getenv(ENV_TRELLAR_API_KEY)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trellar
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Client library for the Trellar confidence evaluation API
|
|
5
|
+
Author: Tomer Ben Harush
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/benarush/AITL
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/benarush/AITL/issues
|
|
9
|
+
Keywords: agent,llm,opentelemetry,confidence,ai
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: requests>=2.28
|
|
22
|
+
Requires-Dist: opentelemetry-sdk>=1.20
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
25
|
+
Provides-Extra: langchain
|
|
26
|
+
Requires-Dist: langchain-core>=0.1; extra == "langchain"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# trellar
|
|
30
|
+
|
|
31
|
+
[](https://pypi.org/project/trellar/)
|
|
32
|
+
[](https://pypi.org/project/trellar/)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+
[](https://github.com/benarush/AITL/actions/workflows/ci.yml)
|
|
35
|
+
|
|
36
|
+
A lightweight Python client for the **Trellar** confidence evaluation API. Attach a callback to your LangChain / LangGraph run, then call `evaluate_confidence()` when you want a score. Context, trace ID, and agent name are picked up automatically — no manual wiring.
|
|
37
|
+
|
|
38
|
+
This library cannot be used without an API key from [trellar.io](https://trellar.io).
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Create an account and API key
|
|
43
|
+
|
|
44
|
+
[trellar.io](https://trellar.io) is the only place that issues API keys for this library. Create an account there, then generate an API key from the dashboard. Without that key, `evaluate_confidence()` cannot authenticate and the client will not work.
|
|
45
|
+
|
|
46
|
+
Then pass the key into the SDK (see [Environment Variables](#environment-variables)):
|
|
47
|
+
|
|
48
|
+
- `evaluate_confidence(api_key="...")`, or
|
|
49
|
+
- `TRELLAR_API_KEY` in the environment
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install "trellar[langchain]"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The `langchain` extra is required because agent runs are captured via a LangChain callback handler (`get_agent_guard`). Requires Python 3.9+.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Quick Start
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from trellar import get_agent_guard, evaluate_confidence
|
|
67
|
+
|
|
68
|
+
# agent_name must be a stable, unique name for this agent graph — the
|
|
69
|
+
# backend uses it to track the graph's network profile across runs.
|
|
70
|
+
guard = get_agent_guard("research-agent")
|
|
71
|
+
|
|
72
|
+
graph.invoke(inputs, config={"callbacks": [guard]})
|
|
73
|
+
|
|
74
|
+
# context, trace_id, and agent_name are picked up from the guard
|
|
75
|
+
result = evaluate_confidence()
|
|
76
|
+
print(result.score) # int, 1-10
|
|
77
|
+
print(result.explanation) # str, human-readable reasoning
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Where to call `evaluate_confidence`
|
|
83
|
+
|
|
84
|
+
Call it from a graph node (or after `invoke()`), at the point in the run you want scored. The payload is the events captured **so far** — later nodes are not included.
|
|
85
|
+
|
|
86
|
+
There are two ways to use the result:
|
|
87
|
+
|
|
88
|
+
### 1. Gate — validate before the graph continues
|
|
89
|
+
|
|
90
|
+
Put the call on an edge you do not want the graph to cross until Trellar has scored the run. Use `result.score` / `result.explanation` to decide whether to proceed or stop.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
def confidence_gate(state):
|
|
94
|
+
result = evaluate_confidence()
|
|
95
|
+
if result.score < 7:
|
|
96
|
+
return {**state, "halt": True, "reason": result.explanation}
|
|
97
|
+
return {**state, "halt": False}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Wire that node in front of the next step, and only continue when the score is acceptable.
|
|
101
|
+
|
|
102
|
+
### 2. Observe — send a validation, do not restrict the graph
|
|
103
|
+
|
|
104
|
+
Put the call anywhere you want a score recorded (a node, or after `invoke()`). Store or log `result` if you want it; do not branch on it. The graph continues either way.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
def report_confidence(state):
|
|
108
|
+
result = evaluate_confidence()
|
|
109
|
+
return {**state, "confidence_score": result.score, "confidence_explanation": result.explanation}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Environment Variables
|
|
115
|
+
|
|
116
|
+
The SDK always talks to the managed Trellar backend at `https://api.trellar.io` — this is fixed and cannot be overridden via an environment variable or function argument.
|
|
117
|
+
|
|
118
|
+
The API key itself is created only at [trellar.io](https://trellar.io). Once you have it, you can pass it to `evaluate_confidence(api_key=...)` or set it as an environment variable so you do not pass it on every call:
|
|
119
|
+
|
|
120
|
+
| Variable | Description | Default |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `TRELLAR_API_KEY` | Bearer token for authentication | *(required)* |
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
export TRELLAR_API_KEY=your-api-key
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
result = evaluate_confidence() # api_key read from the env var
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## API Reference
|
|
135
|
+
|
|
136
|
+
### `get_agent_guard`
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
get_agent_guard(
|
|
140
|
+
agent_name: str,
|
|
141
|
+
observability_mode: ObservabilityMode = ObservabilityMode.NONE,
|
|
142
|
+
) -> BaseCallbackHandler
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
| Parameter | Type | Description |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| `agent_name` | `str` | Stable, unique name identifying this agent graph (e.g. `"research-agent"`) |
|
|
148
|
+
| `observability_mode` | `ObservabilityMode` | Controls whether `evaluate_confidence()` is auto-triggered when the graph run finishes. Default `ObservabilityMode.NONE` (no auto-trigger). |
|
|
149
|
+
|
|
150
|
+
Returns a LangChain callback handler bound to `agent_name`. Pass it to `graph.invoke(..., config={"callbacks": [guard]})`.
|
|
151
|
+
|
|
152
|
+
**Raises:**
|
|
153
|
+
- `ValueError` — if `agent_name` is empty or blank
|
|
154
|
+
|
|
155
|
+
#### `ObservabilityMode`
|
|
156
|
+
|
|
157
|
+
Controls whether the guard automatically calls `evaluate_confidence()` for you when the graph run finishes (the root `graph.invoke()` call completes), so you don't have to add a manual call yourself.
|
|
158
|
+
|
|
159
|
+
| Value | Behavior |
|
|
160
|
+
|---|---|
|
|
161
|
+
| `ObservabilityMode.NONE` | Never auto-call. Default; identical to not passing `observability_mode` at all. |
|
|
162
|
+
| `ObservabilityMode.ALWAYS` | Always call `evaluate_confidence()` when the run finishes. |
|
|
163
|
+
| `ObservabilityMode.IF_NOT_EVALUATED` | Call `evaluate_confidence()` when the run finishes only if it was not already successfully called earlier in the run (e.g. from a gate node). |
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from trellar import get_agent_guard, ObservabilityMode
|
|
167
|
+
|
|
168
|
+
guard = get_agent_guard("research-agent", ObservabilityMode.IF_NOT_EVALUATED)
|
|
169
|
+
graph.invoke(inputs, config={"callbacks": [guard]})
|
|
170
|
+
# evaluate_confidence() has already run automatically if no node called it.
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Auto-triggered calls never raise: any error (missing API key, HTTP error, `NetworkHaltedError`, etc.) is caught and logged instead of propagating out of `graph.invoke()`. A manual call to `evaluate_confidence()` still raises normally.
|
|
174
|
+
|
|
175
|
+
Requests triggered this way are marked in the payload sent to the backend with `observability_call: true` (`false` for a normal, manually-invoked call), so the backend can distinguish automatic observability calls from explicit ones.
|
|
176
|
+
|
|
177
|
+
### `evaluate_confidence`
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
evaluate_confidence(
|
|
181
|
+
*,
|
|
182
|
+
api_key: str | None = None,
|
|
183
|
+
timeout: float = 30.0,
|
|
184
|
+
) -> AgentLoopResult
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
| Parameter | Type | Description |
|
|
188
|
+
|---|---|---|
|
|
189
|
+
| `api_key` | `str \| None` | Bearer token. Falls back to `TRELLAR_API_KEY` |
|
|
190
|
+
| `timeout` | `float` | HTTP request timeout in seconds (default `30.0`) |
|
|
191
|
+
|
|
192
|
+
`context`, `trace_id`, and `agent_name` are resolved automatically from the active guard created by `get_agent_guard` — there is no way to pass them manually. Requests always go to `https://api.trellar.io`; callers cannot redirect them.
|
|
193
|
+
|
|
194
|
+
**Raises:**
|
|
195
|
+
- `ValueError` — if no active guard is found, its `trace_id` cannot be resolved, or `api_key` is missing
|
|
196
|
+
- `requests.HTTPError` — on non-2xx HTTP responses
|
|
197
|
+
|
|
198
|
+
### `AgentLoopResult`
|
|
199
|
+
|
|
200
|
+
A frozen dataclass with two fields:
|
|
201
|
+
|
|
202
|
+
| Field | Type | Description |
|
|
203
|
+
|---|---|---|
|
|
204
|
+
| `score` | `int` | Confidence score from 1 (low) to 10 (high) |
|
|
205
|
+
| `explanation` | `str` | Human-readable explanation of the score |
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
## License
|
|
210
|
+
|
|
211
|
+
MIT — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
trellar/__init__.py,sha256=osjARx8VwnC-_B7w75qhoFXfuvnYvFx7j3pgoK8fGmA,281
|
|
2
|
+
trellar/_context.py,sha256=jduQjDjgFHb5jUc4ghfcRCTCd9P8MSLwBklYVmH5Ngo,317
|
|
3
|
+
trellar/agent_loop.py,sha256=jfhgwNcaqWwfhD5_HTMNqLb37_nqkUZhH64UzGpnc30,6047
|
|
4
|
+
trellar/settings.py,sha256=GbZTCko76oKLNPqICAwBWOZ8rWSjTv2kpYyL_ZqMCQI,251
|
|
5
|
+
trellar/callbacks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
trellar/callbacks/langchain_callback.py,sha256=Nr34RYhi-qe6cfYwSuiOEAcejZdx5ofsjHmm_pcC3cc,27361
|
|
7
|
+
trellar-0.3.0.dist-info/licenses/LICENSE,sha256=RoZk15QLFsQ9Dhl1mYBIZUoUSojuDvCUfsdxnYrbtm0,1073
|
|
8
|
+
trellar-0.3.0.dist-info/METADATA,sha256=PMjyApDO4etKjU97qV3OSXLRo3CpU6aZnH8jrkRLpgU,8110
|
|
9
|
+
trellar-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
trellar-0.3.0.dist-info/top_level.txt,sha256=3aFqxQIPtKMhJtEfqlsaNXPkG8FoZMT-VzlMcQZ2sn8,8
|
|
11
|
+
trellar-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Tomer Ben Harush
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
trellar
|