splox 0.2.7__tar.gz → 0.2.8__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 (38) hide show
  1. {splox-0.2.7 → splox-0.2.8}/PKG-INFO +1 -1
  2. {splox-0.2.7 → splox-0.2.8}/pyproject.toml +1 -1
  3. {splox-0.2.7 → splox-0.2.8}/src/splox/__init__.py +1 -1
  4. splox-0.2.8/src/splox/builder/__init__.py +314 -0
  5. splox-0.2.8/src/splox/evals/__init__.py +552 -0
  6. splox-0.2.8/tests/test_builder.py +329 -0
  7. splox-0.2.8/tests/test_evals.py +307 -0
  8. {splox-0.2.7 → splox-0.2.8}/.github/workflows/publish.yml +0 -0
  9. {splox-0.2.7 → splox-0.2.8}/.github/workflows/test.yml +0 -0
  10. {splox-0.2.7 → splox-0.2.8}/.gitignore +0 -0
  11. {splox-0.2.7 → splox-0.2.8}/CHANGELOG.md +0 -0
  12. {splox-0.2.7 → splox-0.2.8}/LICENSE +0 -0
  13. {splox-0.2.7 → splox-0.2.8}/README.md +0 -0
  14. {splox-0.2.7 → splox-0.2.8}/src/splox/_client.py +0 -0
  15. {splox-0.2.7 → splox-0.2.8}/src/splox/_discovery.py +0 -0
  16. {splox-0.2.7 → splox-0.2.8}/src/splox/_ids.py +0 -0
  17. {splox-0.2.7 → splox-0.2.8}/src/splox/_interactions.py +0 -0
  18. {splox-0.2.7 → splox-0.2.8}/src/splox/_mcp.py +0 -0
  19. {splox-0.2.7 → splox-0.2.8}/src/splox/_models.py +0 -0
  20. {splox-0.2.7 → splox-0.2.8}/src/splox/_resources.py +0 -0
  21. {splox-0.2.7 → splox-0.2.8}/src/splox/_runs.py +0 -0
  22. {splox-0.2.7 → splox-0.2.8}/src/splox/_transport.py +0 -0
  23. {splox-0.2.7 → splox-0.2.8}/src/splox/_workflows.py +0 -0
  24. {splox-0.2.7 → splox-0.2.8}/src/splox/exceptions.py +0 -0
  25. {splox-0.2.7 → splox-0.2.8}/src/splox/py.typed +0 -0
  26. {splox-0.2.7 → splox-0.2.8}/tests/__init__.py +0 -0
  27. {splox-0.2.7 → splox-0.2.8}/tests/integration_test.py +0 -0
  28. {splox-0.2.7 → splox-0.2.8}/tests/integration_test_mcp.py +0 -0
  29. {splox-0.2.7 → splox-0.2.8}/tests/integration_test_v2.py +0 -0
  30. {splox-0.2.7 → splox-0.2.8}/tests/test_client.py +0 -0
  31. {splox-0.2.7 → splox-0.2.8}/tests/test_codemode_contract.py +0 -0
  32. {splox-0.2.7 → splox-0.2.8}/tests/test_discovery.py +0 -0
  33. {splox-0.2.7 → splox-0.2.8}/tests/test_exceptions.py +0 -0
  34. {splox-0.2.7 → splox-0.2.8}/tests/test_ids.py +0 -0
  35. {splox-0.2.7 → splox-0.2.8}/tests/test_models.py +0 -0
  36. {splox-0.2.7 → splox-0.2.8}/tests/test_runs.py +0 -0
  37. {splox-0.2.7 → splox-0.2.8}/tests/test_sse.py +0 -0
  38. {splox-0.2.7 → splox-0.2.8}/tests/test_workflows.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splox
3
- Version: 0.2.7
3
+ Version: 0.2.8
4
4
  Summary: Official Python SDK for the Splox API — run workflows, manage chats, and monitor execution
5
5
  Project-URL: Homepage, https://splox.io
6
6
  Project-URL: Documentation, https://docs.splox.io
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "splox"
7
- version = "0.2.7"
7
+ version = "0.2.8"
8
8
  description = "Official Python SDK for the Splox API — run workflows, manage chats, and monitor execution"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -105,4 +105,4 @@ __all__ = [
105
105
  "async_notify",
106
106
  ]
107
107
 
108
- __version__ = "0.2.7"
108
+ __version__ = "0.2.8"
@@ -0,0 +1,314 @@
1
+ """Splox workflow *builder* — declare an agent graph in Python, push it once.
2
+
3
+ A thin, mostly-pure convenience layer over ``client.workflows`` (the v2 graph
4
+ API in :mod:`splox._workflows`). You describe a workflow as nested
5
+ :func:`agent` / :func:`tool` dictionaries; :func:`upsert` flattens that tree
6
+ into the ``nodes``/``edges`` payload the ``PUT /v2/workflows/{id}/graph``
7
+ endpoint expects, creating the workflow first when it does not yet exist.
8
+
9
+ WORKED EXAMPLE
10
+ --------------
11
+ ::
12
+
13
+ from splox import SploxClient
14
+ from splox.builder import agent, tool, upsert, pull
15
+
16
+ client = SploxClient(api_key="sk-...")
17
+
18
+ research = agent(
19
+ "researcher",
20
+ "You research topics and cite your sources.",
21
+ model="gpt-4o",
22
+ tools=[tool("system:web_search", allow=["search"])],
23
+ )
24
+ root = agent(
25
+ "assistant",
26
+ "You answer the user with help from the researcher sub-agent.",
27
+ subagents=[research],
28
+ )
29
+
30
+ result = upsert(client, "Docs Assistant", root)
31
+ print(result)
32
+ # UpsertResult(workflow_id='wf_01J...', version='wfv_01J...', created=True,
33
+ # node_ids={'assistant': '2f8c...-uuid',
34
+ # 'researcher': 'a1b2...-uuid',
35
+ # 'web_search': 'c7d9...-uuid'})
36
+
37
+ graph = pull(client, result.workflow_id)
38
+ print(graph["nodes"][0])
39
+ # {'id': '2f8c...-uuid', 'type': 'agent', 'label': 'assistant',
40
+ # 'position': {'x': 0.0, 'y': 0.0},
41
+ # 'data': {'system_prompt': 'You answer ...',
42
+ # 'as_tool_code_only': False, 'background': False}}
43
+
44
+ IDEMPOTENCY
45
+ -----------
46
+ :func:`upsert` is keyed on the workflow **name**. It pages through
47
+ ``client.workflows.list()`` and, on an exact name match, replaces that
48
+ workflow's graph via ``set_graph`` (``created=False``); with no match it calls
49
+ ``workflows.create`` first (``created=True``). Re-running ``upsert`` with the
50
+ same name therefore updates the existing workflow in place instead of
51
+ spawning duplicates. Every ``set_graph`` publishes a brand-new, immutable
52
+ workflow *version* (surfaced as ``UpsertResult.version``); ``publish=False``
53
+ stages that version without making it active. ``workflows.create`` itself
54
+ sends an ``Idempotency-Key`` header, so a retried create never double-inserts.
55
+ Client node keys are de-duplicated deterministically — a ``-2`` / ``-3``
56
+ suffix is appended on collision — so identical inputs yield a stable graph.
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import dataclasses
62
+ from dataclasses import dataclass, field
63
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
64
+
65
+ if TYPE_CHECKING: # pragma: no cover - typing only
66
+ from splox import SploxClient
67
+
68
+ __all__ = ["agent", "tool", "upsert", "pull", "UpsertResult"]
69
+
70
+ _SYSTEM_PREFIX = "system:"
71
+
72
+
73
+ @dataclass
74
+ class UpsertResult:
75
+ """Outcome of :func:`upsert`.
76
+
77
+ Attributes:
78
+ workflow_id: ``str`` — the workflow public id (``wf_...``). Sourced from
79
+ the matched ``workflows.list`` entry or a fresh ``workflows.create``
80
+ (both ``/v2/workflows``).
81
+ version: ``str`` — the new active workflow version id (``wfv_...``)
82
+ returned by ``workflows.set_graph`` (``PUT /v2/workflows/{id}/graph``).
83
+ created: ``bool`` — ``True`` when the workflow was created during this
84
+ call, ``False`` when an existing same-named workflow was updated.
85
+ node_ids: ``dict[str, str]`` — client node key -> server node UUID map,
86
+ copied verbatim from the ``set_graph`` response ``node_ids``.
87
+ """
88
+
89
+ workflow_id: str
90
+ version: str
91
+ created: bool
92
+ node_ids: Dict[str, str] = field(default_factory=dict)
93
+
94
+
95
+ def agent(
96
+ key: str,
97
+ system_prompt: str,
98
+ *,
99
+ label: Optional[str] = None,
100
+ model: Optional[str] = None,
101
+ endpoint_id: Optional[str] = None,
102
+ llm_config: Optional[Dict[str, Any]] = None,
103
+ tools: "Tuple[Dict[str, Any], ...]" = (),
104
+ subagents: "Tuple[Dict[str, Any], ...]" = (),
105
+ as_tool: Optional[str] = None,
106
+ as_tool_description: Optional[str] = None,
107
+ code_only: bool = False,
108
+ background: bool = False,
109
+ data_extra: Optional[Dict[str, Any]] = None,
110
+ ) -> Dict[str, Any]:
111
+ """Describe an ``agent`` node (pure — performs no I/O).
112
+
113
+ The returned dict is a builder spec, not a server node: :func:`upsert`
114
+ turns it into an ``agent`` graph node whose ``data`` document is assembled
115
+ from the arguments below and mirrors the v2 agent-node schema.
116
+
117
+ Args:
118
+ key: Client node key; used as the node ``id`` in ``set_graph`` and as
119
+ the ``node_ids`` map key in :class:`UpsertResult`.
120
+ system_prompt: Stored as ``data.system_prompt``.
121
+ label: Optional human-facing node label (``None`` omits it).
122
+ model: Stored as ``data.text_llm_model`` when set.
123
+ endpoint_id: Stored as ``data.text_llm_endpoint_id`` when set.
124
+ llm_config: Stored as ``data.additional_llm_config`` when set.
125
+ tools: :func:`tool` specs to attach via ``tool`` edges.
126
+ subagents: nested :func:`agent` specs to attach via ``tool`` edges.
127
+ as_tool: When this agent is exposed as a callable tool, its tool name
128
+ (``data.as_tool_name``).
129
+ as_tool_description: Description shown to callers (``data.as_tool_description``).
130
+ code_only: Stored as ``data.as_tool_code_only``.
131
+ background: Stored as ``data.background``.
132
+ data_extra: Extra raw ``data`` keys merged last (``dict.update``), so it
133
+ can override any of the above.
134
+
135
+ Returns:
136
+ ``{"key", "type": "agent", "label", "data", "tools", "subagents"}``.
137
+ """
138
+ data: Dict[str, Any] = {"system_prompt": system_prompt}
139
+ if model is not None:
140
+ data["text_llm_model"] = model
141
+ if endpoint_id is not None:
142
+ data["text_llm_endpoint_id"] = endpoint_id
143
+ if llm_config is not None:
144
+ data["additional_llm_config"] = llm_config
145
+ if as_tool is not None:
146
+ data["as_tool_name"] = as_tool
147
+ if as_tool_description is not None:
148
+ data["as_tool_description"] = as_tool_description
149
+ data["as_tool_code_only"] = code_only
150
+ data["background"] = background
151
+ data.update(data_extra or {})
152
+ return {
153
+ "key": key,
154
+ "type": "agent",
155
+ "label": label,
156
+ "data": data,
157
+ "tools": list(tools),
158
+ "subagents": list(subagents),
159
+ }
160
+
161
+
162
+ def tool(
163
+ server_id: str,
164
+ *,
165
+ allow: Optional["List[str]"] = None,
166
+ label: Optional[str] = None,
167
+ ) -> Dict[str, Any]:
168
+ """Describe a ``tool`` node bound to an MCP server (pure — performs no I/O).
169
+
170
+ Args:
171
+ server_id: MCP server id (e.g. ``"system:web_search"``). Stored as
172
+ ``data.mcp_server_id``; the ``system:`` prefix is stripped to form
173
+ the node ``key``.
174
+ allow: Optional allow-list of tool names; when given it is stored as
175
+ ``data.allowed_tools`` (``None`` leaves the server fully enabled).
176
+ label: Optional human-facing node label (``None`` omits it).
177
+
178
+ Returns:
179
+ ``{"key", "type": "tool", "label", "data"}`` where ``data`` carries
180
+ ``mcp_server_id`` and an empty ``tool_approval`` map (plus
181
+ ``allowed_tools`` when ``allow`` is set).
182
+ """
183
+ key = server_id[len(_SYSTEM_PREFIX):] if server_id.startswith(_SYSTEM_PREFIX) else server_id
184
+ data: Dict[str, Any] = {"mcp_server_id": server_id, "tool_approval": {}}
185
+ if allow is not None:
186
+ data["allowed_tools"] = list(allow)
187
+ return {
188
+ "key": key,
189
+ "type": "tool",
190
+ "label": label,
191
+ "data": data,
192
+ }
193
+
194
+
195
+ def _node(spec: Dict[str, Any], node_id: str) -> Dict[str, Any]:
196
+ node: Dict[str, Any] = {
197
+ "id": node_id,
198
+ "type": spec.get("type", "agent"),
199
+ "data": spec.get("data") or {},
200
+ }
201
+ if spec.get("label") is not None:
202
+ node["label"] = spec["label"]
203
+ return node
204
+
205
+
206
+ def _build_graph(
207
+ agents: "Tuple[Dict[str, Any], ...]",
208
+ ) -> "Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]":
209
+ """Flatten builder specs into ``(nodes, edges)`` with unique node ids."""
210
+ nodes: List[Dict[str, Any]] = []
211
+ edges: List[Dict[str, Any]] = []
212
+ counts: Dict[str, int] = {}
213
+
214
+ def unique(key: str) -> str:
215
+ if key not in counts:
216
+ counts[key] = 1
217
+ return key
218
+ candidate = key
219
+ while candidate in counts:
220
+ counts[key] += 1
221
+ candidate = f"{key}-{counts[key]}"
222
+ counts[candidate] = 1
223
+ return candidate
224
+
225
+ def add_agent(spec: Dict[str, Any]) -> str:
226
+ node_id = unique(spec["key"])
227
+ nodes.append(_node(spec, node_id))
228
+ for tool_spec in spec.get("tools") or ():
229
+ tool_id = unique(tool_spec["key"])
230
+ nodes.append(_node(tool_spec, tool_id))
231
+ edges.append({"source": node_id, "target": tool_id, "edge_type": "tool"})
232
+ for sub_spec in spec.get("subagents") or ():
233
+ sub_id = add_agent(sub_spec)
234
+ edges.append({"source": node_id, "target": sub_id, "edge_type": "tool"})
235
+ return node_id
236
+
237
+ for spec in agents:
238
+ add_agent(spec)
239
+ return nodes, edges
240
+
241
+
242
+ def _find_workflow_id(client: "SploxClient", name: str) -> Optional[str]:
243
+ cursor: Optional[str] = None
244
+ while True:
245
+ page = client.workflows.list(cursor=cursor)
246
+ for workflow in page.data:
247
+ if workflow.name == name:
248
+ return workflow.id
249
+ if not page.page.has_more or not page.page.next_cursor:
250
+ return None
251
+ cursor = page.page.next_cursor
252
+
253
+
254
+ def upsert(
255
+ client: "SploxClient",
256
+ name: str,
257
+ *agents: Dict[str, Any],
258
+ graph: Optional[Any] = None,
259
+ publish: bool = True,
260
+ description: str = "",
261
+ ) -> UpsertResult:
262
+ """Create-or-update a workflow named ``name`` and set its graph.
263
+
264
+ This is the only function here that touches the network. When ``graph`` is
265
+ given its ``nodes``/``edges`` (the shape returned by :func:`pull` /
266
+ ``get_graph``) are used verbatim; otherwise the graph is assembled by
267
+ walking ``*agents`` — each :func:`agent` becomes a node, its tools and
268
+ sub-agents become nodes joined by ``tool`` edges (see :func:`_build_graph`).
269
+
270
+ Args:
271
+ client: A :class:`splox.SploxClient`.
272
+ name: Workflow name; the idempotency key for the lookup (see the module
273
+ IDEMPOTENCY section).
274
+ *agents: Root :func:`agent` specs (ignored when ``graph`` is supplied).
275
+ graph: Optional pre-built graph dict/``WorkflowGraph`` to publish as-is.
276
+ publish: Passed through to ``set_graph`` (make the new version active).
277
+ description: Description used only when a new workflow is created.
278
+
279
+ Returns:
280
+ :class:`UpsertResult`.
281
+ """
282
+ if graph is not None:
283
+ source = graph if isinstance(graph, dict) else dataclasses.asdict(graph)
284
+ nodes = list(source.get("nodes") or [])
285
+ edges = list(source.get("edges") or [])
286
+ else:
287
+ nodes, edges = _build_graph(agents)
288
+
289
+ workflow_id = _find_workflow_id(client, name)
290
+ if workflow_id is None:
291
+ workflow = client.workflows.create(name, description)
292
+ workflow_id = workflow.id
293
+ created = True
294
+ else:
295
+ created = False
296
+
297
+ update = client.workflows.set_graph(workflow_id, nodes, edges, publish=publish)
298
+ return UpsertResult(
299
+ workflow_id=workflow_id,
300
+ version=update.workflow_version,
301
+ created=created,
302
+ node_ids=update.node_ids,
303
+ )
304
+
305
+
306
+ def pull(client: "SploxClient", workflow_id: str) -> Dict[str, Any]:
307
+ """Fetch a workflow's active graph as a plain dict.
308
+
309
+ Reads ``client.workflows.get_graph`` (``GET /v2/workflows/{id}/graph``) and
310
+ returns the :class:`splox.WorkflowGraph` as ``{"workflow_version", "nodes",
311
+ "edges"}`` (dataclass -> dict, node/edge ``data`` preserved as-is). The
312
+ result is directly consumable as :func:`upsert`'s ``graph`` argument.
313
+ """
314
+ return dataclasses.asdict(client.workflows.get_graph(workflow_id))