variably-sdk 2.9.0__tar.gz → 2.11.0__tar.gz
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.
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/PKG-INFO +4 -5
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/pyproject.toml +5 -1
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/client.py +56 -0
- variably_sdk-2.11.0/src/variably/integrations/__init__.py +15 -0
- variably_sdk-2.11.0/src/variably/integrations/_common.py +88 -0
- variably_sdk-2.11.0/src/variably/integrations/langgraph.py +202 -0
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/observe.py +27 -0
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/version.py +1 -1
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably_sdk.egg-info/PKG-INFO +4 -5
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably_sdk.egg-info/SOURCES.txt +4 -1
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably_sdk.egg-info/requires.txt +3 -0
- variably_sdk-2.11.0/tests/test_integrations_langgraph.py +131 -0
- variably_sdk-2.9.0/setup.py +0 -23
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/README.md +0 -0
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/setup.cfg +0 -0
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/__init__.py +0 -0
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/cache.py +0 -0
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/config.py +0 -0
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/errors.py +0 -0
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/http_client.py +0 -0
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/logger.py +0 -0
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably/types.py +0 -0
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably_sdk.egg-info/dependency_links.txt +0 -0
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/src/variably_sdk.egg-info/top_level.txt +0 -0
- {variably_sdk-2.9.0 → variably_sdk-2.11.0}/tests/test_streaming.py +0 -0
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: variably-sdk
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.11.0
|
|
4
4
|
Summary: Official Python SDK for Variably feature flags, LLM experimentation, and prompt optimization platform
|
|
5
|
-
Author: Variably
|
|
6
5
|
Author-email: Variably <support@variably.com>
|
|
7
6
|
License: MIT
|
|
8
7
|
Project-URL: Homepage, https://github.com/variably/variably-python-sdk
|
|
@@ -23,10 +22,12 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
23
22
|
Classifier: Programming Language :: Python :: 3.12
|
|
24
23
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
24
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
26
|
-
Requires-Python: >=3.
|
|
25
|
+
Requires-Python: >=3.7
|
|
27
26
|
Description-Content-Type: text/markdown
|
|
28
27
|
Requires-Dist: requests>=2.25.0
|
|
29
28
|
Requires-Dist: typing-extensions>=3.7.4; python_version < "3.8"
|
|
29
|
+
Provides-Extra: langgraph
|
|
30
|
+
Requires-Dist: langgraph>=0.1; extra == "langgraph"
|
|
30
31
|
Provides-Extra: dev
|
|
31
32
|
Requires-Dist: pytest>=6.0; extra == "dev"
|
|
32
33
|
Requires-Dist: pytest-asyncio>=0.18.0; extra == "dev"
|
|
@@ -40,8 +41,6 @@ Provides-Extra: test
|
|
|
40
41
|
Requires-Dist: pytest>=6.0; extra == "test"
|
|
41
42
|
Requires-Dist: pytest-cov>=2.10; extra == "test"
|
|
42
43
|
Requires-Dist: responses>=0.18.0; extra == "test"
|
|
43
|
-
Dynamic: author
|
|
44
|
-
Dynamic: requires-python
|
|
45
44
|
|
|
46
45
|
# Variably Python SDK
|
|
47
46
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "variably-sdk"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.11.0"
|
|
8
8
|
description = "Official Python SDK for Variably feature flags, LLM experimentation, and prompt optimization platform"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.7"
|
|
@@ -35,6 +35,10 @@ dependencies = [
|
|
|
35
35
|
]
|
|
36
36
|
|
|
37
37
|
[project.optional-dependencies]
|
|
38
|
+
# Framework integrations (Task 42.4). Install e.g. `pip install variably-sdk[langgraph]`.
|
|
39
|
+
langgraph = [
|
|
40
|
+
"langgraph>=0.1",
|
|
41
|
+
]
|
|
38
42
|
dev = [
|
|
39
43
|
"pytest>=6.0",
|
|
40
44
|
"pytest-asyncio>=0.18.0",
|
|
@@ -678,6 +678,11 @@ class VariablyClient:
|
|
|
678
678
|
# Multi-turn / Session context
|
|
679
679
|
session_id: Optional[str] = None, # Groups conversation turns into sessions
|
|
680
680
|
conversation_history: Optional[List[Dict[str, Any]]] = None, # Prior turns for coherence scoring
|
|
681
|
+
# Multi-agent context — see docs/improvements/42.1-implementation-plan.md
|
|
682
|
+
agent_name: Optional[str] = None, # This step's agent, within a session_id trace
|
|
683
|
+
is_aggregator: bool = False, # True on the final step that aggregates the trace
|
|
684
|
+
tools_called: Optional[List[Dict[str, Any]]] = None, # [{"name","arguments","output"}] actually called
|
|
685
|
+
expected_tools: Optional[List[Dict[str, Any]]] = None, # golden tools for deterministic trajectory scoring
|
|
681
686
|
# User & organization context
|
|
682
687
|
user_id: Optional[str] = None, # End-user ID for per-user analytics
|
|
683
688
|
tags: Optional[List[str]] = None, # Grouping labels, e.g. ["production", "rag"]
|
|
@@ -689,6 +694,8 @@ class VariablyClient:
|
|
|
689
694
|
prompt_tokens: Optional[int] = None, # Input token count
|
|
690
695
|
completion_tokens: Optional[int] = None, # Output token count
|
|
691
696
|
cost: Optional[float] = None, # Cost in USD
|
|
697
|
+
# Turn-type marker — see _SUBMIT_RESPONSE_KINDS for allowed values.
|
|
698
|
+
kind: Optional[str] = None,
|
|
692
699
|
) -> LogResult:
|
|
693
700
|
"""Log a prompt/response pair for multi-dimensional evaluation (Observe Mode).
|
|
694
701
|
|
|
@@ -722,6 +729,45 @@ class VariablyClient:
|
|
|
722
729
|
# Normalize prompt to string
|
|
723
730
|
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
|
|
724
731
|
|
|
732
|
+
# Validate kind and inject the marker into tags + metadata so the
|
|
733
|
+
# dashboard can suppress grounding/hallucination/attribution
|
|
734
|
+
# scores on non-generative turns. See submit_response for the
|
|
735
|
+
# full rationale; the same logic applies in Observe Mode.
|
|
736
|
+
if kind is not None and kind not in self._SUBMIT_RESPONSE_KINDS:
|
|
737
|
+
raise ConfigurationError(
|
|
738
|
+
f"kind must be one of {self._SUBMIT_RESPONSE_KINDS}, got {kind!r}",
|
|
739
|
+
"kind",
|
|
740
|
+
)
|
|
741
|
+
if kind and kind != "generative":
|
|
742
|
+
kind_tag = f"kind:{kind}"
|
|
743
|
+
tags = list(tags) if tags else []
|
|
744
|
+
if kind_tag not in tags:
|
|
745
|
+
tags.append(kind_tag)
|
|
746
|
+
metadata = dict(metadata) if metadata else {}
|
|
747
|
+
metadata.setdefault("kind", kind)
|
|
748
|
+
|
|
749
|
+
# Validate multi-agent inputs up front (mirrors server-side checks) so
|
|
750
|
+
# a malformed trace fails fast at the call site rather than silently
|
|
751
|
+
# producing an unscoreable session.
|
|
752
|
+
if is_aggregator and not agent_name:
|
|
753
|
+
raise ConfigurationError(
|
|
754
|
+
"is_aggregator=True requires a non-empty agent_name",
|
|
755
|
+
"agent_name",
|
|
756
|
+
)
|
|
757
|
+
if is_aggregator and not session_id:
|
|
758
|
+
raise ConfigurationError(
|
|
759
|
+
"is_aggregator=True requires a session_id to assemble the multi-agent trace",
|
|
760
|
+
"session_id",
|
|
761
|
+
)
|
|
762
|
+
for _label, _tools in (("tools_called", tools_called), ("expected_tools", expected_tools)):
|
|
763
|
+
if _tools:
|
|
764
|
+
for _t in _tools:
|
|
765
|
+
if not isinstance(_t, dict) or not _t.get("name"):
|
|
766
|
+
raise ConfigurationError(
|
|
767
|
+
f"{_label} entries must be dicts with a non-empty 'name'",
|
|
768
|
+
_label,
|
|
769
|
+
)
|
|
770
|
+
|
|
725
771
|
data: Dict[str, Any] = {
|
|
726
772
|
"prompt": prompt_str,
|
|
727
773
|
"response": response,
|
|
@@ -744,6 +790,14 @@ class VariablyClient:
|
|
|
744
790
|
data["user_id"] = user_id
|
|
745
791
|
if session_id:
|
|
746
792
|
data["session_id"] = session_id
|
|
793
|
+
if agent_name:
|
|
794
|
+
data["agent_name"] = agent_name
|
|
795
|
+
if is_aggregator:
|
|
796
|
+
data["is_aggregator"] = True
|
|
797
|
+
if tools_called:
|
|
798
|
+
data["tools_called"] = tools_called
|
|
799
|
+
if expected_tools:
|
|
800
|
+
data["expected_tools"] = expected_tools
|
|
747
801
|
if provider:
|
|
748
802
|
data["provider"] = provider
|
|
749
803
|
if model:
|
|
@@ -758,6 +812,8 @@ class VariablyClient:
|
|
|
758
812
|
data["cost"] = cost
|
|
759
813
|
if metadata:
|
|
760
814
|
data["metadata"] = metadata
|
|
815
|
+
if kind:
|
|
816
|
+
data["kind"] = kind
|
|
761
817
|
|
|
762
818
|
try:
|
|
763
819
|
result = self.http_client.log_observation(data)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Framework auto-instrumentation for variА/Bly (improvement #42, Task 42.4).
|
|
2
|
+
|
|
3
|
+
Per-framework integrations that wire multi-agent workflows into variА/Bly's
|
|
4
|
+
deterministic cross-step evaluation with little or no per-node code:
|
|
5
|
+
|
|
6
|
+
from variably.integrations.langgraph import instrument
|
|
7
|
+
instrument(graph, session_id_key="session_id")
|
|
8
|
+
|
|
9
|
+
Each integration maps the framework's node/agent identity to ``agent_name``,
|
|
10
|
+
detects the terminal node(s) as ``is_aggregator``, pulls session id + context
|
|
11
|
+
from the framework's state, and logs via ``variably.observe`` — always
|
|
12
|
+
best-effort, never raising into the customer's workflow.
|
|
13
|
+
|
|
14
|
+
Install extras per framework, e.g. ``pip install variably-sdk[langgraph]``.
|
|
15
|
+
"""
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Shared helpers for variА/Bly framework integrations (Task 42.4).
|
|
2
|
+
|
|
3
|
+
Best-effort extraction of the fields the evaluator needs (response text, an
|
|
4
|
+
optional query/prompt, and the session id) from a framework's state / node
|
|
5
|
+
return value. Everything here is defensive: extraction never raises, so a
|
|
6
|
+
framework whose state shape we don't recognise degrades to an empty string
|
|
7
|
+
rather than breaking the customer's workflow.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
# Keys we look for when extracting a human-readable response from a node's
|
|
16
|
+
# return value, in priority order.
|
|
17
|
+
_RESPONSE_KEYS = ("response", "output", "answer", "result", "text", "content")
|
|
18
|
+
# Keys we look for when extracting the user query/prompt from graph state.
|
|
19
|
+
_QUERY_KEYS = ("query", "question", "input", "prompt", "user_input")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def warn(msg: str) -> None:
|
|
23
|
+
print(f"[variably.integrations] {msg}", file=sys.stderr)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _message_text(msgs: Any) -> str:
|
|
27
|
+
"""Extract text from a LangChain-style messages list (or single message)."""
|
|
28
|
+
try:
|
|
29
|
+
last = msgs[-1] if isinstance(msgs, (list, tuple)) and msgs else msgs
|
|
30
|
+
content = getattr(last, "content", None)
|
|
31
|
+
if isinstance(content, str):
|
|
32
|
+
return content
|
|
33
|
+
if isinstance(last, dict) and isinstance(last.get("content"), str):
|
|
34
|
+
return last["content"]
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
return ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def extract_response_text(update: Any) -> str:
|
|
41
|
+
"""Best-effort extraction of response text from a node's return value."""
|
|
42
|
+
try:
|
|
43
|
+
if update is None:
|
|
44
|
+
return ""
|
|
45
|
+
if isinstance(update, str):
|
|
46
|
+
return update
|
|
47
|
+
if isinstance(update, dict):
|
|
48
|
+
for k in _RESPONSE_KEYS:
|
|
49
|
+
v = update.get(k)
|
|
50
|
+
if isinstance(v, str) and v.strip():
|
|
51
|
+
return v
|
|
52
|
+
if "messages" in update:
|
|
53
|
+
text = _message_text(update["messages"])
|
|
54
|
+
if text:
|
|
55
|
+
return text
|
|
56
|
+
for v in update.values(): # fallback: first non-empty string
|
|
57
|
+
if isinstance(v, str) and v.strip():
|
|
58
|
+
return v
|
|
59
|
+
return ""
|
|
60
|
+
# objects: try common attributes
|
|
61
|
+
for k in _RESPONSE_KEYS:
|
|
62
|
+
v = getattr(update, k, None)
|
|
63
|
+
if isinstance(v, str) and v.strip():
|
|
64
|
+
return v
|
|
65
|
+
return str(update)
|
|
66
|
+
except Exception:
|
|
67
|
+
return ""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def extract_query(state: Any) -> str:
|
|
71
|
+
"""Best-effort extraction of the user query/prompt from graph state."""
|
|
72
|
+
try:
|
|
73
|
+
get = state.get if isinstance(state, dict) else (lambda k, d=None: getattr(state, k, d))
|
|
74
|
+
for k in _QUERY_KEYS:
|
|
75
|
+
v = get(k)
|
|
76
|
+
if isinstance(v, str) and v.strip():
|
|
77
|
+
return v
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
return ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def extract_session_id(state: Any, key: str) -> Optional[str]:
|
|
84
|
+
try:
|
|
85
|
+
v = state.get(key) if isinstance(state, dict) else getattr(state, key, None)
|
|
86
|
+
return str(v) if v else None
|
|
87
|
+
except Exception:
|
|
88
|
+
return None
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""LangGraph auto-instrumentation for variА/Bly (Task 42.4).
|
|
2
|
+
|
|
3
|
+
Two entry points:
|
|
4
|
+
|
|
5
|
+
1. ``instrument(graph, session_id_key=...)`` — zero-touch. Call it once after
|
|
6
|
+
you've added nodes/edges (before ``.compile()``). Wraps every node so each
|
|
7
|
+
execution logs to variА/Bly: ``agent_name`` = node name, ``is_aggregator``
|
|
8
|
+
on the graph's terminal node(s), ``session_id`` pulled from graph state.
|
|
9
|
+
|
|
10
|
+
2. ``@observe_node(...)`` — an explicit per-node decorator. Version-independent
|
|
11
|
+
and the most robust option if ``instrument`` can't wrap your graph's node
|
|
12
|
+
representation.
|
|
13
|
+
|
|
14
|
+
Both are best-effort: instrumentation failures degrade to a stderr warning and
|
|
15
|
+
NEVER raise into your graph. Targets langgraph >= 0.1; call ``instrument`` after
|
|
16
|
+
``add_node`` / ``add_edge`` and before ``compile()``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import functools
|
|
22
|
+
from typing import Any, Callable, Iterable, Optional
|
|
23
|
+
|
|
24
|
+
from variably import observe as _observe
|
|
25
|
+
from variably.integrations._common import (
|
|
26
|
+
extract_query,
|
|
27
|
+
extract_response_text,
|
|
28
|
+
extract_session_id,
|
|
29
|
+
warn,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _log_node(
|
|
34
|
+
name: str,
|
|
35
|
+
state: Any,
|
|
36
|
+
update: Any,
|
|
37
|
+
*,
|
|
38
|
+
is_aggregator: bool,
|
|
39
|
+
session_id_key: str,
|
|
40
|
+
response_extractor: Optional[Callable[[Any], str]],
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Log one node execution to variА/Bly. Never raises."""
|
|
43
|
+
try:
|
|
44
|
+
resp = (response_extractor or extract_response_text)(update)
|
|
45
|
+
sid = extract_session_id(state, session_id_key)
|
|
46
|
+
# is_aggregator requires a session_id (SDK write-time rule); only claim
|
|
47
|
+
# it when we actually have one, else it degrades to a normal step.
|
|
48
|
+
agg = bool(is_aggregator and sid)
|
|
49
|
+
_observe(
|
|
50
|
+
prompt=extract_query(state) or f"[node:{name}]",
|
|
51
|
+
response=resp or "",
|
|
52
|
+
session_id=sid,
|
|
53
|
+
agent_name=name,
|
|
54
|
+
is_aggregator=agg,
|
|
55
|
+
)
|
|
56
|
+
except Exception as e: # pragma: no cover - defensive
|
|
57
|
+
warn(f"node '{name}' instrumentation failed: {e}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def observe_node(
|
|
61
|
+
*,
|
|
62
|
+
agent_name: Optional[str] = None,
|
|
63
|
+
is_aggregator: bool = False,
|
|
64
|
+
session_id_key: str = "session_id",
|
|
65
|
+
response_extractor: Optional[Callable[[Any], str]] = None,
|
|
66
|
+
) -> Callable[[Callable], Callable]:
|
|
67
|
+
"""Decorator for a LangGraph node function. Logs the node's output to
|
|
68
|
+
variА/Bly after it runs. ``agent_name`` defaults to the function name."""
|
|
69
|
+
|
|
70
|
+
def decorate(fn: Callable) -> Callable:
|
|
71
|
+
name = agent_name or getattr(fn, "__name__", "node")
|
|
72
|
+
|
|
73
|
+
@functools.wraps(fn)
|
|
74
|
+
def wrapper(state, *args, **kwargs):
|
|
75
|
+
update = fn(state, *args, **kwargs)
|
|
76
|
+
_log_node(
|
|
77
|
+
name, state, update,
|
|
78
|
+
is_aggregator=is_aggregator,
|
|
79
|
+
session_id_key=session_id_key,
|
|
80
|
+
response_extractor=response_extractor,
|
|
81
|
+
)
|
|
82
|
+
return update
|
|
83
|
+
|
|
84
|
+
return wrapper
|
|
85
|
+
|
|
86
|
+
return decorate
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _detect_terminal_nodes(graph) -> set:
|
|
90
|
+
"""Terminal nodes = those with an edge to END, plus any with no outgoing
|
|
91
|
+
edge. Best-effort across langgraph versions."""
|
|
92
|
+
terminals: set = set()
|
|
93
|
+
try:
|
|
94
|
+
try:
|
|
95
|
+
from langgraph.graph import END
|
|
96
|
+
except Exception:
|
|
97
|
+
END = "__end__"
|
|
98
|
+
|
|
99
|
+
node_names = set(getattr(graph, "nodes", {}) or {})
|
|
100
|
+
edges = getattr(graph, "edges", None) or set()
|
|
101
|
+
has_outgoing: set = set()
|
|
102
|
+
for edge in edges:
|
|
103
|
+
try:
|
|
104
|
+
u, v = edge
|
|
105
|
+
except Exception:
|
|
106
|
+
continue
|
|
107
|
+
has_outgoing.add(u)
|
|
108
|
+
if v == END or v == "__end__":
|
|
109
|
+
terminals.add(u)
|
|
110
|
+
# nodes with no outgoing plain edge (may route via conditional branches,
|
|
111
|
+
# but this is a reasonable default when no END edge is declared)
|
|
112
|
+
terminals |= (node_names - has_outgoing)
|
|
113
|
+
except Exception as e: # pragma: no cover - defensive
|
|
114
|
+
warn(f"terminal-node detection failed: {e}")
|
|
115
|
+
return terminals
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _wrap_callable(name, fn, is_agg, session_id_key, response_extractor):
|
|
119
|
+
@functools.wraps(fn)
|
|
120
|
+
def wrapped(state, *args, **kwargs):
|
|
121
|
+
update = fn(state, *args, **kwargs)
|
|
122
|
+
_log_node(
|
|
123
|
+
name, state, update,
|
|
124
|
+
is_aggregator=is_agg,
|
|
125
|
+
session_id_key=session_id_key,
|
|
126
|
+
response_extractor=response_extractor,
|
|
127
|
+
)
|
|
128
|
+
return update
|
|
129
|
+
return wrapped
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def instrument(
|
|
133
|
+
graph,
|
|
134
|
+
*,
|
|
135
|
+
session_id_key: str = "session_id",
|
|
136
|
+
aggregator_nodes: Optional[Iterable[str]] = None,
|
|
137
|
+
response_extractor: Optional[Callable[[Any], str]] = None,
|
|
138
|
+
):
|
|
139
|
+
"""Instrument every node in a LangGraph ``StateGraph`` in place.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
graph: an uncompiled ``StateGraph``.
|
|
143
|
+
session_id_key: state key holding the session/thread id.
|
|
144
|
+
aggregator_nodes: override terminal-node auto-detection with an explicit set.
|
|
145
|
+
response_extractor: custom fn to pull response text from a node's return.
|
|
146
|
+
|
|
147
|
+
Returns the same graph (for chaining). Best-effort; never raises.
|
|
148
|
+
"""
|
|
149
|
+
nodes = getattr(graph, "nodes", None)
|
|
150
|
+
if not nodes:
|
|
151
|
+
warn("graph has no .nodes — nothing to instrument (pass an uncompiled StateGraph)")
|
|
152
|
+
return graph
|
|
153
|
+
|
|
154
|
+
terminals = set(aggregator_nodes) if aggregator_nodes is not None else _detect_terminal_nodes(graph)
|
|
155
|
+
|
|
156
|
+
for name in list(nodes.keys()):
|
|
157
|
+
is_agg = name in terminals
|
|
158
|
+
try:
|
|
159
|
+
spec = nodes[name]
|
|
160
|
+
# langgraph StateNodeSpec exposes the node fn on .runnable; older /
|
|
161
|
+
# simpler representations store the callable directly.
|
|
162
|
+
runnable = getattr(spec, "runnable", None)
|
|
163
|
+
if runnable is not None:
|
|
164
|
+
inner = getattr(runnable, "func", None) or runnable
|
|
165
|
+
if not callable(inner):
|
|
166
|
+
warn(f"node '{name}': runnable is not callable; skipped")
|
|
167
|
+
continue
|
|
168
|
+
wrapped = _wrap_callable(name, inner, is_agg, session_id_key, response_extractor)
|
|
169
|
+
if hasattr(runnable, "func"):
|
|
170
|
+
try:
|
|
171
|
+
runnable.func = wrapped # RunnableCallable/Lambda
|
|
172
|
+
continue
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
if _replace_spec_runnable(nodes, name, spec, wrapped):
|
|
176
|
+
continue
|
|
177
|
+
warn(f"node '{name}': could not rewrap runnable (unsupported spec)")
|
|
178
|
+
elif callable(spec):
|
|
179
|
+
nodes[name] = _wrap_callable(name, spec, is_agg, session_id_key, response_extractor)
|
|
180
|
+
else:
|
|
181
|
+
warn(f"node '{name}': unrecognised spec type {type(spec).__name__}; skipped")
|
|
182
|
+
except Exception as e: # pragma: no cover - defensive
|
|
183
|
+
warn(f"failed to instrument node '{name}': {e}")
|
|
184
|
+
|
|
185
|
+
return graph
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _replace_spec_runnable(nodes, name, spec, wrapped) -> bool:
|
|
189
|
+
"""Try to put `wrapped` back onto the node spec across spec representations."""
|
|
190
|
+
# settable attribute
|
|
191
|
+
try:
|
|
192
|
+
spec.runnable = wrapped # type: ignore[attr-defined]
|
|
193
|
+
return True
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
# namedtuple-style: rebuild via _replace
|
|
197
|
+
try:
|
|
198
|
+
nodes[name] = spec._replace(runnable=wrapped) # type: ignore[attr-defined]
|
|
199
|
+
return True
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
return False
|
|
@@ -172,11 +172,33 @@ def observe(
|
|
|
172
172
|
# Advanced: not needed if you pass session_id (variA/Bly auto-builds history from previous turns).
|
|
173
173
|
prior_turns: Optional[List[Dict[str, str]]] = None,
|
|
174
174
|
|
|
175
|
+
# --- Multi-agent context ---
|
|
176
|
+
# Turn the session_id grouping into an evaluatable multi-agent trace.
|
|
177
|
+
# agent_name labels which agent produced this step; set is_aggregator=True
|
|
178
|
+
# on the final step that produces the aggregated answer to trigger
|
|
179
|
+
# deterministic cross-step scoring (coherence + trajectory).
|
|
180
|
+
agent_name: Optional[str] = None,
|
|
181
|
+
is_aggregator: bool = False,
|
|
182
|
+
# Tools this step actually called / was expected to call. Each dict:
|
|
183
|
+
# {"name": "...", "arguments": {...}, "output": "..."}. Enables the
|
|
184
|
+
# deterministic trajectory precision/recall + tool-correctness metrics.
|
|
185
|
+
tools_called: Optional[List[Dict[str, Any]]] = None,
|
|
186
|
+
expected_tools: Optional[List[Dict[str, Any]]] = None,
|
|
187
|
+
|
|
175
188
|
# --- User & organization context ---
|
|
176
189
|
user_id: Optional[str] = None, # Your end-user's ID — enables per-user analytics
|
|
177
190
|
tags: Optional[List[str]] = None, # Grouping labels, e.g. ["production", "rag", "v2"]
|
|
178
191
|
metadata: Optional[Dict[str, Any]] = None, # Any arbitrary key-value data for filtering
|
|
179
192
|
|
|
193
|
+
# --- Turn type ---
|
|
194
|
+
# Marks what kind of LLM turn this is. Use "classifier" / "router" /
|
|
195
|
+
# "tool_use" / "transformer" for non-generative turns so the dashboard
|
|
196
|
+
# suppresses grounding/hallucination/attribution scores (which don't
|
|
197
|
+
# apply to JSON control structures or function calls). Default
|
|
198
|
+
# "generative" preserves existing behavior.
|
|
199
|
+
# Allowed: "generative" (default), "classifier", "router", "tool_use", "transformer".
|
|
200
|
+
kind: Optional[str] = None,
|
|
201
|
+
|
|
180
202
|
# --- Backward compatibility ---
|
|
181
203
|
conversation_history: Optional[List[Dict[str, str]]] = None, # Deprecated: use prior_turns instead
|
|
182
204
|
) -> Optional[Dict[str, Any]]:
|
|
@@ -262,7 +284,12 @@ def observe(
|
|
|
262
284
|
tags=tags,
|
|
263
285
|
user_id=user_id,
|
|
264
286
|
session_id=session_id,
|
|
287
|
+
agent_name=agent_name,
|
|
288
|
+
is_aggregator=is_aggregator,
|
|
289
|
+
tools_called=tools_called,
|
|
290
|
+
expected_tools=expected_tools,
|
|
265
291
|
metadata=metadata,
|
|
292
|
+
kind=kind,
|
|
266
293
|
)
|
|
267
294
|
|
|
268
295
|
return {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: variably-sdk
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.11.0
|
|
4
4
|
Summary: Official Python SDK for Variably feature flags, LLM experimentation, and prompt optimization platform
|
|
5
|
-
Author: Variably
|
|
6
5
|
Author-email: Variably <support@variably.com>
|
|
7
6
|
License: MIT
|
|
8
7
|
Project-URL: Homepage, https://github.com/variably/variably-python-sdk
|
|
@@ -23,10 +22,12 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
23
22
|
Classifier: Programming Language :: Python :: 3.12
|
|
24
23
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
24
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
26
|
-
Requires-Python: >=3.
|
|
25
|
+
Requires-Python: >=3.7
|
|
27
26
|
Description-Content-Type: text/markdown
|
|
28
27
|
Requires-Dist: requests>=2.25.0
|
|
29
28
|
Requires-Dist: typing-extensions>=3.7.4; python_version < "3.8"
|
|
29
|
+
Provides-Extra: langgraph
|
|
30
|
+
Requires-Dist: langgraph>=0.1; extra == "langgraph"
|
|
30
31
|
Provides-Extra: dev
|
|
31
32
|
Requires-Dist: pytest>=6.0; extra == "dev"
|
|
32
33
|
Requires-Dist: pytest-asyncio>=0.18.0; extra == "dev"
|
|
@@ -40,8 +41,6 @@ Provides-Extra: test
|
|
|
40
41
|
Requires-Dist: pytest>=6.0; extra == "test"
|
|
41
42
|
Requires-Dist: pytest-cov>=2.10; extra == "test"
|
|
42
43
|
Requires-Dist: responses>=0.18.0; extra == "test"
|
|
43
|
-
Dynamic: author
|
|
44
|
-
Dynamic: requires-python
|
|
45
44
|
|
|
46
45
|
# Variably Python SDK
|
|
47
46
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
README.md
|
|
2
2
|
pyproject.toml
|
|
3
|
-
setup.py
|
|
4
3
|
src/variably/__init__.py
|
|
5
4
|
src/variably/cache.py
|
|
6
5
|
src/variably/client.py
|
|
@@ -11,9 +10,13 @@ src/variably/logger.py
|
|
|
11
10
|
src/variably/observe.py
|
|
12
11
|
src/variably/types.py
|
|
13
12
|
src/variably/version.py
|
|
13
|
+
src/variably/integrations/__init__.py
|
|
14
|
+
src/variably/integrations/_common.py
|
|
15
|
+
src/variably/integrations/langgraph.py
|
|
14
16
|
src/variably_sdk.egg-info/PKG-INFO
|
|
15
17
|
src/variably_sdk.egg-info/SOURCES.txt
|
|
16
18
|
src/variably_sdk.egg-info/dependency_links.txt
|
|
17
19
|
src/variably_sdk.egg-info/requires.txt
|
|
18
20
|
src/variably_sdk.egg-info/top_level.txt
|
|
21
|
+
tests/test_integrations_langgraph.py
|
|
19
22
|
tests/test_streaming.py
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Tests for the LangGraph integration (Task 42.4).
|
|
2
|
+
|
|
3
|
+
Uses a fake StateGraph and a captured `observe` so no real langgraph is needed.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
10
|
+
|
|
11
|
+
import variably.integrations.langgraph as lg
|
|
12
|
+
from variably.integrations.langgraph import instrument, observe_node
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FakeGraph:
|
|
16
|
+
"""Minimal StateGraph stand-in: nodes as name->callable, edges as a set."""
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.nodes = {}
|
|
19
|
+
self.edges = set()
|
|
20
|
+
|
|
21
|
+
def add_node(self, name, fn):
|
|
22
|
+
self.nodes[name] = fn
|
|
23
|
+
|
|
24
|
+
def add_edge(self, u, v):
|
|
25
|
+
self.edges.add((u, v))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _capture(monkeypatch):
|
|
29
|
+
calls = []
|
|
30
|
+
monkeypatch.setattr(lg, "_observe", lambda **kw: calls.append(kw))
|
|
31
|
+
return calls
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_observe_node_decorator(monkeypatch):
|
|
35
|
+
calls = _capture(monkeypatch)
|
|
36
|
+
|
|
37
|
+
@observe_node(agent_name="reco", is_aggregator=True)
|
|
38
|
+
def node(state):
|
|
39
|
+
return {"response": "final recommendation"}
|
|
40
|
+
|
|
41
|
+
out = node({"session_id": "s1", "query": "what to buy?"})
|
|
42
|
+
assert out == {"response": "final recommendation"}
|
|
43
|
+
assert len(calls) == 1
|
|
44
|
+
c = calls[0]
|
|
45
|
+
assert c["agent_name"] == "reco"
|
|
46
|
+
assert c["is_aggregator"] is True
|
|
47
|
+
assert c["session_id"] == "s1"
|
|
48
|
+
assert c["response"] == "final recommendation"
|
|
49
|
+
assert c["prompt"] == "what to buy?"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_is_aggregator_requires_session_id(monkeypatch):
|
|
53
|
+
calls = _capture(monkeypatch)
|
|
54
|
+
|
|
55
|
+
@observe_node(agent_name="reco", is_aggregator=True)
|
|
56
|
+
def node(state):
|
|
57
|
+
return {"response": "x"}
|
|
58
|
+
|
|
59
|
+
node({}) # no session_id
|
|
60
|
+
assert calls[0]["is_aggregator"] is False
|
|
61
|
+
assert calls[0]["session_id"] is None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_instrument_wraps_all_nodes_and_marks_terminal(monkeypatch):
|
|
65
|
+
calls = _capture(monkeypatch)
|
|
66
|
+
g = FakeGraph()
|
|
67
|
+
g.add_node("profiler", lambda s: {"profile": "eco-friendly"})
|
|
68
|
+
g.add_node("recommender", lambda s: {"response": "buy X"})
|
|
69
|
+
g.add_edge("profiler", "recommender")
|
|
70
|
+
g.add_edge("recommender", "__end__") # terminal
|
|
71
|
+
|
|
72
|
+
instrument(g, session_id_key="session_id")
|
|
73
|
+
|
|
74
|
+
state = {"session_id": "sess", "query": "q"}
|
|
75
|
+
g.nodes["profiler"](state)
|
|
76
|
+
g.nodes["recommender"](state)
|
|
77
|
+
|
|
78
|
+
by_agent = {c["agent_name"]: c for c in calls}
|
|
79
|
+
assert set(by_agent) == {"profiler", "recommender"}
|
|
80
|
+
assert by_agent["profiler"]["is_aggregator"] is False
|
|
81
|
+
assert by_agent["recommender"]["is_aggregator"] is True
|
|
82
|
+
assert by_agent["profiler"]["response"] == "eco-friendly"
|
|
83
|
+
assert by_agent["recommender"]["response"] == "buy X"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_instrument_never_breaks_node_when_logging_fails(monkeypatch):
|
|
87
|
+
def boom(**kw):
|
|
88
|
+
raise RuntimeError("logging down")
|
|
89
|
+
monkeypatch.setattr(lg, "_observe", boom)
|
|
90
|
+
|
|
91
|
+
g = FakeGraph()
|
|
92
|
+
g.add_node("n", lambda s: {"response": "still works"})
|
|
93
|
+
instrument(g)
|
|
94
|
+
# node must still return its update even though logging raises
|
|
95
|
+
assert g.nodes["n"]({"session_id": "s"}) == {"response": "still works"}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_instrument_rewraps_runnable_spec(monkeypatch):
|
|
99
|
+
calls = _capture(monkeypatch)
|
|
100
|
+
|
|
101
|
+
class FakeRunnable:
|
|
102
|
+
def __init__(self, func):
|
|
103
|
+
self.func = func
|
|
104
|
+
|
|
105
|
+
class Spec:
|
|
106
|
+
def __init__(self, runnable):
|
|
107
|
+
self.runnable = runnable
|
|
108
|
+
|
|
109
|
+
g = FakeGraph()
|
|
110
|
+
g.nodes["x"] = Spec(FakeRunnable(lambda s: {"response": "r"}))
|
|
111
|
+
g.add_edge("x", "__end__")
|
|
112
|
+
|
|
113
|
+
instrument(g)
|
|
114
|
+
# the wrapped fn now lives on .runnable.func
|
|
115
|
+
g.nodes["x"].runnable.func({"session_id": "s"})
|
|
116
|
+
assert len(calls) == 1
|
|
117
|
+
assert calls[0]["agent_name"] == "x"
|
|
118
|
+
assert calls[0]["is_aggregator"] is True
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_explicit_aggregator_override(monkeypatch):
|
|
122
|
+
calls = _capture(monkeypatch)
|
|
123
|
+
g = FakeGraph()
|
|
124
|
+
g.add_node("a", lambda s: {"response": "1"})
|
|
125
|
+
g.add_node("b", lambda s: {"response": "2"})
|
|
126
|
+
instrument(g, aggregator_nodes={"a"})
|
|
127
|
+
g.nodes["a"]({"session_id": "s"})
|
|
128
|
+
g.nodes["b"]({"session_id": "s"})
|
|
129
|
+
by_agent = {c["agent_name"]: c for c in calls}
|
|
130
|
+
assert by_agent["a"]["is_aggregator"] is True
|
|
131
|
+
assert by_agent["b"]["is_aggregator"] is False
|
variably_sdk-2.9.0/setup.py
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
from setuptools import setup, find_packages
|
|
2
|
-
|
|
3
|
-
setup(
|
|
4
|
-
name="variably",
|
|
5
|
-
version="2.4.0",
|
|
6
|
-
description="Variably Python SDK for LLM experimentation platform",
|
|
7
|
-
author="Variably",
|
|
8
|
-
packages=find_packages(),
|
|
9
|
-
install_requires=[
|
|
10
|
-
"requests>=2.25.0",
|
|
11
|
-
],
|
|
12
|
-
python_requires=">=3.8",
|
|
13
|
-
classifiers=[
|
|
14
|
-
"Development Status :: 4 - Beta",
|
|
15
|
-
"Intended Audience :: Developers",
|
|
16
|
-
"License :: OSI Approved :: MIT License",
|
|
17
|
-
"Programming Language :: Python :: 3",
|
|
18
|
-
"Programming Language :: Python :: 3.8",
|
|
19
|
-
"Programming Language :: Python :: 3.9",
|
|
20
|
-
"Programming Language :: Python :: 3.10",
|
|
21
|
-
"Programming Language :: Python :: 3.11",
|
|
22
|
-
],
|
|
23
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|