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.
Files changed (31) hide show
  1. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/PKG-INFO +1 -1
  2. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/pyproject.toml +1 -1
  3. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/client.py +6 -1
  4. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/integrations/adk.py +2 -0
  5. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/integrations/langgraph.py +38 -2
  6. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/observe.py +9 -1
  7. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/version.py +1 -1
  8. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably_sdk.egg-info/PKG-INFO +1 -1
  9. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/tests/test_integrations_adk.py +2 -0
  10. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/tests/test_integrations_langgraph.py +23 -0
  11. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/README.md +0 -0
  12. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/setup.cfg +0 -0
  13. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/__init__.py +0 -0
  14. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/assignment.py +0 -0
  15. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/cache.py +0 -0
  16. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/config.py +0 -0
  17. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/errors.py +0 -0
  18. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/http_client.py +0 -0
  19. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/integrations/__init__.py +0 -0
  20. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/integrations/_common.py +0 -0
  21. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/integrations/otel.py +0 -0
  22. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/logger.py +0 -0
  23. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/prompt_config.py +0 -0
  24. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably/types.py +0 -0
  25. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably_sdk.egg-info/SOURCES.txt +0 -0
  26. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably_sdk.egg-info/dependency_links.txt +0 -0
  27. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably_sdk.egg-info/requires.txt +0 -0
  28. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/src/variably_sdk.egg-info/top_level.txt +0 -0
  29. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/tests/test_integrations_otel.py +0 -0
  30. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/tests/test_prompt_assignment.py +0 -0
  31. {variably_sdk-2.13.0 → variably_sdk-2.14.0}/tests/test_streaming.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: variably-sdk
3
- Version: 2.13.0
3
+ Version: 2.14.0
4
4
  Summary: Official Python SDK for Variably feature flags, LLM experimentation, and prompt optimization platform
5
5
  Author-email: Variably <support@variably.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "variably-sdk"
7
- version = "2.13.0"
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 / tool-correctness / order-adherence
240
- metrics. Return None when the node makes no tool calls.
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": "..."}. Enables the
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
  )
@@ -1,3 +1,3 @@
1
1
  """Version information for Variably SDK."""
2
2
 
3
- __version__ = "2.13.0"
3
+ __version__ = "2.14.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: variably-sdk
3
- Version: 2.13.0
3
+ Version: 2.14.0
4
4
  Summary: Official Python SDK for Variably feature flags, LLM experimentation, and prompt optimization platform
5
5
  Author-email: Variably <support@variably.com>
6
6
  License: MIT
@@ -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