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 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
+ [![PyPI version](https://img.shields.io/pypi/v/trellar.svg)](https://pypi.org/project/trellar/)
32
+ [![Python](https://img.shields.io/pypi/pyversions/trellar.svg)](https://pypi.org/project/trellar/)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
34
+ [![CI](https://github.com/benarush/AITL/actions/workflows/ci.yml/badge.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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