crtrs-driver 0.1.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.
- crtrs_driver-0.1.0/LICENSE +21 -0
- crtrs_driver-0.1.0/PKG-INFO +39 -0
- crtrs_driver-0.1.0/README.md +26 -0
- crtrs_driver-0.1.0/examples/run.py +57 -0
- crtrs_driver-0.1.0/pyproject.toml +21 -0
- crtrs_driver-0.1.0/src/crtrs/driver/__init__.py +154 -0
- crtrs_driver-0.1.0/tests/test_smoke.py +80 -0
- crtrs_driver-0.1.0/uv.lock +8 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CREATORS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: crtrs-driver
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for Driver cloud — run agents, stream events.
|
|
5
|
+
Project-URL: Homepage, https://driver.tors.app
|
|
6
|
+
Project-URL: Issues, https://github.com/CREATORS-INDUSTRIES/driver-python/issues
|
|
7
|
+
Author: CREATORS
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agent,ai,cloud,crtrs,driver,sse
|
|
11
|
+
Requires-Python: >=3.8
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# crtrs-driver
|
|
15
|
+
|
|
16
|
+
Python client for Driver. Run agents, stream events.
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pip install crtrs-driver
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from crtrs.driver import Driver
|
|
24
|
+
|
|
25
|
+
driver = Driver(api_key="dr_...") # or DRIVER_API_KEY
|
|
26
|
+
|
|
27
|
+
# Stream events.
|
|
28
|
+
for ev in driver.stream("what is https://ycombinator.com about?"):
|
|
29
|
+
if ev["kind"] == "action":
|
|
30
|
+
print(ev["tool"])
|
|
31
|
+
|
|
32
|
+
# ...or run to completion.
|
|
33
|
+
done = driver.run("what is https://ycombinator.com about?")
|
|
34
|
+
print(done["result"])
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Events: `plan`, `plan_item_start`, `action`, `done`, `fatal`.
|
|
38
|
+
|
|
39
|
+
MIT
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# crtrs-driver
|
|
2
|
+
|
|
3
|
+
Python client for Driver. Run agents, stream events.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pip install crtrs-driver
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from crtrs.driver import Driver
|
|
11
|
+
|
|
12
|
+
driver = Driver(api_key="dr_...") # or DRIVER_API_KEY
|
|
13
|
+
|
|
14
|
+
# Stream events.
|
|
15
|
+
for ev in driver.stream("what is https://ycombinator.com about?"):
|
|
16
|
+
if ev["kind"] == "action":
|
|
17
|
+
print(ev["tool"])
|
|
18
|
+
|
|
19
|
+
# ...or run to completion.
|
|
20
|
+
done = driver.run("what is https://ycombinator.com about?")
|
|
21
|
+
print(done["result"])
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Events: `plan`, `plan_item_start`, `action`, `done`, `fatal`.
|
|
25
|
+
|
|
26
|
+
MIT
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Manual example: run a real prompt against Driver cloud and print events live.
|
|
2
|
+
|
|
3
|
+
export DRIVER_API_KEY=dr_xxxxxxxx
|
|
4
|
+
python examples/run.py "what is https://ycombinator.com about?"
|
|
5
|
+
|
|
6
|
+
Optional:
|
|
7
|
+
export DRIVER_BASE_URL=https://driver.tors.app
|
|
8
|
+
export DRIVER_DEBUG=1 # dump raw events
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from crtrs.driver import Driver, DriverError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> int:
|
|
18
|
+
if not os.environ.get("DRIVER_API_KEY"):
|
|
19
|
+
print("set DRIVER_API_KEY (dr_...) — get one from the dashboard", file=sys.stderr)
|
|
20
|
+
return 2
|
|
21
|
+
|
|
22
|
+
prompt = " ".join(sys.argv[1:]) or "what is https://ycombinator.com about?"
|
|
23
|
+
driver = Driver() # reads DRIVER_API_KEY / DRIVER_BASE_URL from env
|
|
24
|
+
debug = bool(os.environ.get("DRIVER_DEBUG"))
|
|
25
|
+
|
|
26
|
+
print(f"prompt: {prompt}")
|
|
27
|
+
done = None
|
|
28
|
+
try:
|
|
29
|
+
for ev in driver.stream(prompt):
|
|
30
|
+
if debug:
|
|
31
|
+
print("RAW", ev, file=sys.stderr)
|
|
32
|
+
kind = ev["kind"]
|
|
33
|
+
if kind == "plan":
|
|
34
|
+
print("\n[plan]")
|
|
35
|
+
for i, it in enumerate(ev.get("items") or []):
|
|
36
|
+
print(f" {i + 1}. {it}")
|
|
37
|
+
elif kind == "plan_item_start":
|
|
38
|
+
print(f"\n[->] {ev['num'] + 1}. {ev['def']}")
|
|
39
|
+
elif kind == "action":
|
|
40
|
+
net = " [net]" if ev.get("is_network") else ""
|
|
41
|
+
print(f" . {ev['tool']}{net}")
|
|
42
|
+
elif kind == "done":
|
|
43
|
+
done = ev
|
|
44
|
+
except DriverError as e:
|
|
45
|
+
print(f"\nfatal: {e}", file=sys.stderr)
|
|
46
|
+
return 1
|
|
47
|
+
|
|
48
|
+
if done is not None:
|
|
49
|
+
print(f"\n[done] steps={done.get('steps')} errors={done.get('errors')}")
|
|
50
|
+
print("RESULT:", done.get("result"))
|
|
51
|
+
for d in done.get("data") or []:
|
|
52
|
+
print(f" {d['var']} ({d['label']}): {len(d['value'])} chars")
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "crtrs-driver"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for Driver cloud — run agents, stream events."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "CREATORS" }]
|
|
13
|
+
keywords = ["driver", "agent", "ai", "sse", "cloud", "crtrs"]
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://driver.tors.app"
|
|
18
|
+
Issues = "https://github.com/CREATORS-INDUSTRIES/driver-python/issues"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/crtrs"]
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Python client for Driver cloud — run agents, stream events.
|
|
2
|
+
|
|
3
|
+
Mirror of the Node client (`@crtrs/driver`): POST a prompt to
|
|
4
|
+
``/api/driver/run`` with a ``dr_`` API key, then stream the agent's events back
|
|
5
|
+
over SSE in real time.
|
|
6
|
+
|
|
7
|
+
Only five event kinds are surfaced — ``plan``, ``plan_item_start``, ``action``,
|
|
8
|
+
``done``, ``fatal`` — and nothing else (internal kinds, raw tool ids, action
|
|
9
|
+
args and raw error messages are dropped client-side).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
from typing import Callable, Dict, Iterator, List, Optional
|
|
19
|
+
|
|
20
|
+
__all__ = ["Driver", "DriverError"]
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE_URL = "https://driver.tors.app"
|
|
24
|
+
RUN_PATH = "/api/driver/run"
|
|
25
|
+
|
|
26
|
+
# The only event kinds the client surfaces; everything else is dropped.
|
|
27
|
+
ALLOWED_KINDS = frozenset({"plan", "plan_item_start", "action", "done", "fatal"})
|
|
28
|
+
|
|
29
|
+
Event = Dict[str, object]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DriverError(Exception):
|
|
33
|
+
"""Raised on a failed run or a ``fatal`` event (carries the error category)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Driver:
|
|
37
|
+
"""Client for Driver cloud.
|
|
38
|
+
|
|
39
|
+
:param api_key: the ``dr_…`` API key (machine credential). Falls back to the
|
|
40
|
+
``DRIVER_API_KEY`` environment variable.
|
|
41
|
+
:param base_url: cloud base URL. Falls back to ``DRIVER_BASE_URL`` then the
|
|
42
|
+
default (``https://driver.tors.app``).
|
|
43
|
+
:param timeout: per-read socket timeout in seconds (``None`` = no timeout).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
api_key: Optional[str] = None,
|
|
49
|
+
base_url: Optional[str] = None,
|
|
50
|
+
timeout: Optional[float] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
key = api_key or os.environ.get("DRIVER_API_KEY")
|
|
53
|
+
if not key:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"Driver: missing api_key (pass api_key= or set DRIVER_API_KEY)"
|
|
56
|
+
)
|
|
57
|
+
self.api_key = key
|
|
58
|
+
self.base_url = (base_url or os.environ.get("DRIVER_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
59
|
+
self.timeout = timeout
|
|
60
|
+
|
|
61
|
+
def stream(self, prompt: str) -> Iterator[Event]:
|
|
62
|
+
"""Run a prompt and yield each agent event as it arrives.
|
|
63
|
+
|
|
64
|
+
Yields the five allowlisted event kinds. Raises :class:`DriverError` on a
|
|
65
|
+
``fatal`` event or a non-2xx response.
|
|
66
|
+
"""
|
|
67
|
+
req = urllib.request.Request(
|
|
68
|
+
self.base_url + RUN_PATH,
|
|
69
|
+
data=json.dumps({"prompt": prompt}).encode("utf-8"),
|
|
70
|
+
method="POST",
|
|
71
|
+
headers={
|
|
72
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
73
|
+
"Content-Type": "application/json",
|
|
74
|
+
"Accept": "text/event-stream",
|
|
75
|
+
# Default urllib UA ("Python-urllib/3.x") trips Cloudflare bot
|
|
76
|
+
# filtering and gets a 403; send our own instead.
|
|
77
|
+
"User-Agent": f"crtrs-driver/{__version__}",
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
try:
|
|
81
|
+
resp = urllib.request.urlopen(req, timeout=self.timeout)
|
|
82
|
+
except urllib.error.HTTPError as e:
|
|
83
|
+
body = _safe_read(e)
|
|
84
|
+
raise DriverError(
|
|
85
|
+
f"Driver run failed: HTTP {e.code} {e.reason}" + (f" — {body}" if body else "")
|
|
86
|
+
) from None
|
|
87
|
+
|
|
88
|
+
with resp:
|
|
89
|
+
for ev in _parse_sse(resp):
|
|
90
|
+
# Allowlist: only surface the public kinds so internal events
|
|
91
|
+
# can never leak to the caller.
|
|
92
|
+
if ev.get("kind") not in ALLOWED_KINDS:
|
|
93
|
+
continue
|
|
94
|
+
if ev["kind"] == "fatal":
|
|
95
|
+
# Only the error category is exposed; raw message stays server-side.
|
|
96
|
+
raise DriverError(str(ev.get("semantic") or "fatal"))
|
|
97
|
+
yield ev
|
|
98
|
+
|
|
99
|
+
def run(self, prompt: str, on_event: Optional[Callable[[Event], None]] = None) -> Optional[Event]:
|
|
100
|
+
"""Run a prompt to completion.
|
|
101
|
+
|
|
102
|
+
:param on_event: optional callback invoked for every event.
|
|
103
|
+
:returns: the final ``done`` event, or ``None`` if the stream ended
|
|
104
|
+
without one.
|
|
105
|
+
"""
|
|
106
|
+
done: Optional[Event] = None
|
|
107
|
+
for ev in self.stream(prompt):
|
|
108
|
+
if on_event is not None:
|
|
109
|
+
on_event(ev)
|
|
110
|
+
if ev["kind"] == "done":
|
|
111
|
+
done = ev
|
|
112
|
+
return done
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _safe_read(e: urllib.error.HTTPError) -> str:
|
|
116
|
+
try:
|
|
117
|
+
return e.read().decode("utf-8", "replace")[:500]
|
|
118
|
+
except Exception:
|
|
119
|
+
return ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _parse_sse(resp) -> Iterator[Event]:
|
|
123
|
+
"""Yield parsed JSON events from an SSE stream.
|
|
124
|
+
|
|
125
|
+
Handles multi-line ``data:`` fields and blank-line event delimiters.
|
|
126
|
+
``data`` blocks that aren't valid JSON are skipped (never surfaced as raw
|
|
127
|
+
text).
|
|
128
|
+
"""
|
|
129
|
+
data_lines: List[str] = []
|
|
130
|
+
for raw in resp:
|
|
131
|
+
line = raw.decode("utf-8", "replace").rstrip("\r\n")
|
|
132
|
+
if line == "": # blank line = end of one SSE event
|
|
133
|
+
ev = _parse_event(data_lines)
|
|
134
|
+
data_lines = []
|
|
135
|
+
if ev is not None:
|
|
136
|
+
yield ev
|
|
137
|
+
continue
|
|
138
|
+
if line.startswith(":"): # comment / heartbeat
|
|
139
|
+
continue
|
|
140
|
+
if line.startswith("data:"):
|
|
141
|
+
data_lines.append(line[5:].lstrip(" "))
|
|
142
|
+
|
|
143
|
+
ev = _parse_event(data_lines) # flush trailing event with no final blank line
|
|
144
|
+
if ev is not None:
|
|
145
|
+
yield ev
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _parse_event(data_lines: List[str]) -> Optional[Event]:
|
|
149
|
+
if not data_lines:
|
|
150
|
+
return None
|
|
151
|
+
try:
|
|
152
|
+
return json.loads("\n".join(data_lines))
|
|
153
|
+
except json.JSONDecodeError:
|
|
154
|
+
return None # non-JSON data is dropped, never surfaced as raw text
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Smoke test: API surface + SSE parsing against a mock urlopen (no network)."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
9
|
+
|
|
10
|
+
import crtrs.driver as crtrs_driver
|
|
11
|
+
from crtrs.driver import Driver, DriverError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeResponse(io.BytesIO):
|
|
15
|
+
"""Byte stream that also works as a context manager (like an HTTP response)."""
|
|
16
|
+
|
|
17
|
+
def __enter__(self):
|
|
18
|
+
return self
|
|
19
|
+
|
|
20
|
+
def __exit__(self, *a):
|
|
21
|
+
self.close()
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def make_urlopen(captured, body, expect_prompt):
|
|
26
|
+
def fake_urlopen(req, timeout=None):
|
|
27
|
+
captured["url"] = req.full_url
|
|
28
|
+
captured["auth"] = req.headers.get("Authorization")
|
|
29
|
+
assert json.loads(req.data) == {"prompt": expect_prompt}
|
|
30
|
+
return FakeResponse(body.encode("utf-8"))
|
|
31
|
+
|
|
32
|
+
return fake_urlopen
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_requires_api_key():
|
|
36
|
+
os.environ.pop("DRIVER_API_KEY", None)
|
|
37
|
+
try:
|
|
38
|
+
Driver()
|
|
39
|
+
except ValueError as e:
|
|
40
|
+
assert "missing api_key" in str(e)
|
|
41
|
+
else:
|
|
42
|
+
raise AssertionError("should require an api_key")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_stream_allowlist():
|
|
46
|
+
captured = {}
|
|
47
|
+
body = (
|
|
48
|
+
'data: {"kind":"plan","items":["a","b"]}\n\n'
|
|
49
|
+
'data: {"kind":"step","def":"internal leak"}\n\n' # hidden kind: dropped
|
|
50
|
+
"data: not-json should be dropped\n\n" # non-JSON: dropped
|
|
51
|
+
'data: {"kind":"action","tool":"http fetching","is_network":true}\n\n'
|
|
52
|
+
'data: {"kind":"done","result":"ok","steps":2,"errors":0}\n\n'
|
|
53
|
+
)
|
|
54
|
+
crtrs_driver.urllib.request.urlopen = make_urlopen(captured, body, "hi")
|
|
55
|
+
|
|
56
|
+
driver = Driver(api_key="dr_test")
|
|
57
|
+
kinds = [ev["kind"] for ev in driver.stream("hi")]
|
|
58
|
+
|
|
59
|
+
assert captured["url"].endswith("/api/driver/run")
|
|
60
|
+
assert captured["auth"] == "Bearer dr_test"
|
|
61
|
+
assert kinds == ["plan", "action", "done"], kinds
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_fatal_raises():
|
|
65
|
+
body = 'data: {"kind":"fatal","semantic":"provider"}\n\n'
|
|
66
|
+
crtrs_driver.urllib.request.urlopen = make_urlopen({}, body, "boom")
|
|
67
|
+
driver = Driver(api_key="dr_test")
|
|
68
|
+
try:
|
|
69
|
+
list(driver.stream("boom"))
|
|
70
|
+
except DriverError as e:
|
|
71
|
+
assert str(e) == "provider"
|
|
72
|
+
else:
|
|
73
|
+
raise AssertionError("fatal should raise DriverError")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
test_requires_api_key()
|
|
78
|
+
test_stream_allowlist()
|
|
79
|
+
test_fatal_raises()
|
|
80
|
+
print("ok — crtrs-driver smoke test passed")
|