variably-sdk 2.13.0__tar.gz → 2.14.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.13.0 → variably_sdk-2.14.0}/PKG-INFO +1 -1
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/pyproject.toml +1 -1
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/client.py +6 -1
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/integrations/adk.py +2 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/integrations/langgraph.py +38 -2
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/observe.py +9 -1
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/version.py +1 -1
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably_sdk.egg-info/PKG-INFO +1 -1
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/tests/test_integrations_adk.py +2 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/tests/test_integrations_langgraph.py +23 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/README.md +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/setup.cfg +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/__init__.py +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/assignment.py +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/cache.py +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/config.py +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/errors.py +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/http_client.py +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/integrations/__init__.py +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/integrations/_common.py +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/integrations/otel.py +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/logger.py +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/prompt_config.py +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/types.py +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably_sdk.egg-info/SOURCES.txt +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably_sdk.egg-info/dependency_links.txt +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably_sdk.egg-info/requires.txt +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably_sdk.egg-info/top_level.txt +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/tests/test_integrations_otel.py +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/tests/test_prompt_assignment.py +0 -0
- {variably_sdk-2.13.0 → variably_sdk-2.14.0}/tests/test_streaming.py +0 -0
|
@@ -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.14.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"
|
|
@@ -819,9 +819,10 @@ class VariablyClient:
|
|
|
819
819
|
# Multi-agent context — see docs/improvements/42.1-implementation-plan.md
|
|
820
820
|
agent_name: Optional[str] = None, # This step's agent, within a session_id trace
|
|
821
821
|
is_aggregator: bool = False, # True on the final step that aggregates the trace
|
|
822
|
-
tools_called: Optional[List[Dict[str, Any]]] = None, # [{"name","arguments","output"}] actually called
|
|
822
|
+
tools_called: Optional[List[Dict[str, Any]]] = None, # [{"name","arguments","output","error"}] actually called; "error" on a failed call
|
|
823
823
|
expected_tools: Optional[List[Dict[str, Any]]] = None, # golden tools for deterministic trajectory scoring
|
|
824
824
|
allowed_tools: Optional[List[str]] = None, # permitted tool names; calls outside this list flag as unauthorized
|
|
825
|
+
max_steps: Optional[int] = None, # the golden's budget of tool calls for this run
|
|
825
826
|
# User & organization context
|
|
826
827
|
user_id: Optional[str] = None, # End-user ID for per-user analytics
|
|
827
828
|
tags: Optional[List[str]] = None, # Grouping labels, e.g. ["production", "rag"]
|
|
@@ -939,6 +940,10 @@ class VariablyClient:
|
|
|
939
940
|
data["expected_tools"] = expected_tools
|
|
940
941
|
if allowed_tools:
|
|
941
942
|
data["allowed_tools"] = allowed_tools
|
|
943
|
+
if max_steps is not None:
|
|
944
|
+
if not isinstance(max_steps, int) or isinstance(max_steps, bool) or max_steps < 1:
|
|
945
|
+
raise ValueError(f"max_steps must be a positive integer, got {max_steps!r}")
|
|
946
|
+
data["max_steps"] = max_steps
|
|
942
947
|
if provider:
|
|
943
948
|
data["provider"] = provider
|
|
944
949
|
if model:
|
|
@@ -490,6 +490,8 @@ class VariablyPlugin(BasePlugin):
|
|
|
490
490
|
entry: Dict[str, Any] = {"name": name, "arguments": _args(tool_args)}
|
|
491
491
|
if error is not None:
|
|
492
492
|
entry["output"] = f"ERROR: {type(error).__name__}: {error}"[:500]
|
|
493
|
+
# Structured: a failed call is a wasted step (never read from output text).
|
|
494
|
+
entry["error"] = f"{type(error).__name__}: {error}"[:500]
|
|
493
495
|
else:
|
|
494
496
|
entry["output"] = _text_of(result)
|
|
495
497
|
extractor = self._extractors.get(name)
|
|
@@ -149,6 +149,40 @@ def observe_node(
|
|
|
149
149
|
return decorate
|
|
150
150
|
|
|
151
151
|
|
|
152
|
+
def tool_calls_from_messages(messages: Iterable[Any]) -> list:
|
|
153
|
+
"""``tools_called`` from LangChain messages, for a ``tools_extractor``.
|
|
154
|
+
|
|
155
|
+
Each tool call an ``AIMessage`` requested (``.tool_calls``) is matched to the
|
|
156
|
+
``ToolMessage`` that answered it (by ``tool_call_id``): its content becomes the
|
|
157
|
+
call's ``output``, and a ``ToolMessage`` with ``status="error"`` — how
|
|
158
|
+
LangGraph's ``ToolNode`` reports a tool that raised — sets ``error``, so the
|
|
159
|
+
failed call counts as a wasted step. Calls never answered are kept without an
|
|
160
|
+
output. Duck-typed: works with message objects or their dict form::
|
|
161
|
+
|
|
162
|
+
instrument(graph, tools_extractor=lambda state, update: {
|
|
163
|
+
"tools_called": tool_calls_from_messages(update.get("messages", [])),
|
|
164
|
+
})
|
|
165
|
+
"""
|
|
166
|
+
def get(m, k, default=None):
|
|
167
|
+
return m.get(k, default) if isinstance(m, dict) else getattr(m, k, default)
|
|
168
|
+
|
|
169
|
+
calls, by_id = [], {}
|
|
170
|
+
for m in messages or []:
|
|
171
|
+
for tc in get(m, "tool_calls", None) or []:
|
|
172
|
+
entry = {"name": get(tc, "name", "") or "", "arguments": dict(get(tc, "args", None) or {})}
|
|
173
|
+
calls.append(entry)
|
|
174
|
+
if get(tc, "id"):
|
|
175
|
+
by_id[get(tc, "id")] = entry
|
|
176
|
+
tid = get(m, "tool_call_id")
|
|
177
|
+
if tid and tid in by_id:
|
|
178
|
+
content = get(m, "content", "")
|
|
179
|
+
text = content if isinstance(content, str) else str(content)
|
|
180
|
+
by_id[tid]["output"] = text[:2000]
|
|
181
|
+
if get(m, "status") == "error":
|
|
182
|
+
by_id[tid]["error"] = text[:500] or "error"
|
|
183
|
+
return calls
|
|
184
|
+
|
|
185
|
+
|
|
152
186
|
def _detect_terminal_nodes(graph) -> set:
|
|
153
187
|
"""Terminal nodes = those with an edge to END, plus any with no outgoing
|
|
154
188
|
edge. Best-effort across langgraph versions."""
|
|
@@ -236,8 +270,10 @@ def instrument(
|
|
|
236
270
|
grounding cannot be scored (only the non-grounding dimensions).
|
|
237
271
|
tools_extractor: custom fn ``(state, update) -> {"tools_called": [...],
|
|
238
272
|
"expected_tools": [...]}`` (each tool a ``{"name","arguments","output"}``
|
|
239
|
-
dict) enabling the trajectory /
|
|
240
|
-
|
|
273
|
+
dict, plus ``"error"`` on a failed call) enabling the trajectory /
|
|
274
|
+
tool-correctness / order-adherence and step-efficiency metrics.
|
|
275
|
+
``tool_calls_from_messages`` builds ``tools_called`` from the
|
|
276
|
+
standard LangChain messages. Return None when the node makes no tool calls.
|
|
241
277
|
|
|
242
278
|
Returns the same graph (for chaining). Best-effort; never raises.
|
|
243
279
|
"""
|
|
@@ -185,13 +185,20 @@ def observe(
|
|
|
185
185
|
agent_name: Optional[str] = None,
|
|
186
186
|
is_aggregator: bool = False,
|
|
187
187
|
# Tools this step actually called / was expected to call. Each dict:
|
|
188
|
-
# {"name": "...", "arguments": {...}, "output": "..."}.
|
|
188
|
+
# {"name": "...", "arguments": {...}, "output": "...", "error": "..."}.
|
|
189
|
+
# Set "error" (a short message) on a call that failed — the tool raised,
|
|
190
|
+
# timed out or exited non-zero: a failed call is a wasted step in Step
|
|
191
|
+
# efficiency (failures are never read from the output text). Enables the
|
|
189
192
|
# deterministic trajectory precision/recall + tool-correctness metrics.
|
|
190
193
|
tools_called: Optional[List[Dict[str, Any]]] = None,
|
|
191
194
|
expected_tools: Optional[List[Dict[str, Any]]] = None,
|
|
192
195
|
# Permitted-tool allowlist (names). Any call outside it flags as an
|
|
193
196
|
# unauthorized action — the deterministic security/adversarial signal.
|
|
194
197
|
allowed_tools: Optional[List[str]] = None,
|
|
198
|
+
# The golden's budget of tool calls for this run. A run over it fails Step
|
|
199
|
+
# efficiency; without it, only wasted calls (failed, repeated, after the
|
|
200
|
+
# golden's final tool) count against the run.
|
|
201
|
+
max_steps: Optional[int] = None,
|
|
195
202
|
|
|
196
203
|
# --- User & organization context ---
|
|
197
204
|
user_id: Optional[str] = None, # Your end-user's ID — enables per-user analytics
|
|
@@ -297,6 +304,7 @@ def observe(
|
|
|
297
304
|
tools_called=tools_called,
|
|
298
305
|
expected_tools=expected_tools,
|
|
299
306
|
allowed_tools=allowed_tools,
|
|
307
|
+
max_steps=max_steps,
|
|
300
308
|
metadata=metadata,
|
|
301
309
|
kind=kind,
|
|
302
310
|
)
|
|
@@ -164,6 +164,8 @@ def test_tool_error_recorded(captured):
|
|
|
164
164
|
o = _by_agent(captured)["solo"]
|
|
165
165
|
assert o["tools_called"][0]["name"] == "broken_tool"
|
|
166
166
|
assert o["tools_called"][0]["output"].startswith("ERROR: RuntimeError: upstream 503")
|
|
167
|
+
# Structured, so Step efficiency counts the failed call as wasted (v4.1.1).
|
|
168
|
+
assert o["tools_called"][0]["error"] == "RuntimeError: upstream 503"
|
|
167
169
|
assert "upstream 503" in o["metadata"]["error"]
|
|
168
170
|
|
|
169
171
|
|
|
@@ -129,3 +129,26 @@ def test_explicit_aggregator_override(monkeypatch):
|
|
|
129
129
|
by_agent = {c["agent_name"]: c for c in calls}
|
|
130
130
|
assert by_agent["a"]["is_aggregator"] is True
|
|
131
131
|
assert by_agent["b"]["is_aggregator"] is False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class _Msg:
|
|
135
|
+
def __init__(self, **kw):
|
|
136
|
+
self.__dict__.update(kw)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_tool_calls_from_messages_marks_failed_calls():
|
|
140
|
+
from variably.integrations.langgraph import tool_calls_from_messages
|
|
141
|
+
msgs = [
|
|
142
|
+
_Msg(content="", tool_calls=[{"name": "search", "args": {"q": "refund"}, "id": "t1"},
|
|
143
|
+
{"name": "get_order", "args": {"id": "A1"}, "id": "t2"}]),
|
|
144
|
+
_Msg(content="refunds take 5 days", tool_call_id="t1", status="success"),
|
|
145
|
+
_Msg(content="Error: TimeoutError('upstream')", tool_call_id="t2", status="error"),
|
|
146
|
+
{"tool_calls": [{"name": "get_order", "args": {"id": "A1"}, "id": "t3"}]},
|
|
147
|
+
{"tool_call_id": "t3", "content": "order A1: shipped"},
|
|
148
|
+
]
|
|
149
|
+
calls = tool_calls_from_messages(msgs)
|
|
150
|
+
assert [c["name"] for c in calls] == ["search", "get_order", "get_order"]
|
|
151
|
+
assert calls[0] == {"name": "search", "arguments": {"q": "refund"}, "output": "refunds take 5 days"}
|
|
152
|
+
assert calls[1]["error"] == "Error: TimeoutError('upstream')"
|
|
153
|
+
assert "error" not in calls[2] and calls[2]["output"] == "order A1: shipped"
|
|
154
|
+
assert tool_calls_from_messages(None) == []
|
|
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
|
|
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
|