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.
- {splox-0.2.7 → splox-0.2.8}/PKG-INFO +1 -1
- {splox-0.2.7 → splox-0.2.8}/pyproject.toml +1 -1
- {splox-0.2.7 → splox-0.2.8}/src/splox/__init__.py +1 -1
- splox-0.2.8/src/splox/builder/__init__.py +314 -0
- splox-0.2.8/src/splox/evals/__init__.py +552 -0
- splox-0.2.8/tests/test_builder.py +329 -0
- splox-0.2.8/tests/test_evals.py +307 -0
- {splox-0.2.7 → splox-0.2.8}/.github/workflows/publish.yml +0 -0
- {splox-0.2.7 → splox-0.2.8}/.github/workflows/test.yml +0 -0
- {splox-0.2.7 → splox-0.2.8}/.gitignore +0 -0
- {splox-0.2.7 → splox-0.2.8}/CHANGELOG.md +0 -0
- {splox-0.2.7 → splox-0.2.8}/LICENSE +0 -0
- {splox-0.2.7 → splox-0.2.8}/README.md +0 -0
- {splox-0.2.7 → splox-0.2.8}/src/splox/_client.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/src/splox/_discovery.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/src/splox/_ids.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/src/splox/_interactions.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/src/splox/_mcp.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/src/splox/_models.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/src/splox/_resources.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/src/splox/_runs.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/src/splox/_transport.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/src/splox/_workflows.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/src/splox/exceptions.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/src/splox/py.typed +0 -0
- {splox-0.2.7 → splox-0.2.8}/tests/__init__.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/tests/integration_test.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/tests/integration_test_mcp.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/tests/integration_test_v2.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/tests/test_client.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/tests/test_codemode_contract.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/tests/test_discovery.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/tests/test_exceptions.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/tests/test_ids.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/tests/test_models.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/tests/test_runs.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/tests/test_sse.py +0 -0
- {splox-0.2.7 → splox-0.2.8}/tests/test_workflows.py +0 -0
|
@@ -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))
|