sdxloop-worker 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.
@@ -0,0 +1,222 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
219
+
220
+ # sdxloop
221
+ .sdxloop/
222
+ packages/sdxloop/src/sdxloop/_vendor/
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: sdxloop-worker
3
+ Version: 0.1.0
4
+ Summary: In-sandbox worker runtime for sdxloop (protocol models, agent backends, job runner)
5
+ Project-URL: Homepage, https://github.com/brettbergin/sdxloop
6
+ Project-URL: Repository, https://github.com/brettbergin/sdxloop
7
+ Author: Brett Bergin
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: pydantic>=2.7
18
+ Provides-Extra: copilot
19
+ Requires-Dist: github-copilot-sdk>=1.0.8; extra == 'copilot'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # sdxloop-worker
23
+
24
+ The in-sandbox runtime for [sdxloop](https://github.com/brettbergin/sdxloop):
25
+ shared host/worker protocol models, the job runner (`python -m sdxloop_worker`),
26
+ and agent backends. Install with the `copilot` extra inside agent sandboxes:
27
+
28
+ ```bash
29
+ pip install "sdxloop-worker[copilot]"
30
+ ```
31
+
32
+ You normally never install this directly — the sdxloop host package provisions
33
+ it into sandboxes automatically.
@@ -0,0 +1,12 @@
1
+ # sdxloop-worker
2
+
3
+ The in-sandbox runtime for [sdxloop](https://github.com/brettbergin/sdxloop):
4
+ shared host/worker protocol models, the job runner (`python -m sdxloop_worker`),
5
+ and agent backends. Install with the `copilot` extra inside agent sandboxes:
6
+
7
+ ```bash
8
+ pip install "sdxloop-worker[copilot]"
9
+ ```
10
+
11
+ You normally never install this directly — the sdxloop host package provisions
12
+ it into sandboxes automatically.
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "sdxloop-worker"
3
+ version = "0.1.0"
4
+ description = "In-sandbox worker runtime for sdxloop (protocol models, agent backends, job runner)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ authors = [{ name = "Brett Bergin" }]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Intended Audience :: Developers",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.11",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Typing :: Typed",
17
+ ]
18
+ dependencies = ["pydantic>=2.7"]
19
+
20
+ [project.optional-dependencies]
21
+ copilot = ["github-copilot-sdk>=1.0.8"]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/brettbergin/sdxloop"
25
+ Repository = "https://github.com/brettbergin/sdxloop"
26
+
27
+ [build-system]
28
+ requires = ["hatchling>=1.27"]
29
+ build-backend = "hatchling.build"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/sdxloop_worker"]
@@ -0,0 +1,10 @@
1
+ """sdxloop-worker — the in-sandbox runtime for sdxloop.
2
+
3
+ Runs inside a Docker Sandbox microVM. Hosts the shared host/worker protocol
4
+ models, the agent backends (GitHub Copilot SDK), and the job runner invoked
5
+ via ``python -m sdxloop_worker``.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = ["__version__"]
@@ -0,0 +1,89 @@
1
+ """Worker entry point: ``python -m sdxloop_worker run --job J --events E --result R``.
2
+
3
+ Exit codes: 0 when a result file was written (including error/timeout
4
+ results — the result file is the outcome channel); 64 for usage errors;
5
+ 70 when the result could not be produced at all.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import re
13
+ import shlex
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from sdxloop_worker.protocol import JobRequest
18
+ from sdxloop_worker.runner import JobRunner
19
+
20
+ DEFAULT_ENV_FILE = Path.home() / ".sdxloop" / "env.sh"
21
+
22
+ _EXPORT_RE = re.compile(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
23
+
24
+
25
+ def load_env_file(path: Path) -> dict[str, str]:
26
+ """Parse ``export KEY=VALUE`` lines (shell-quoted values supported).
27
+
28
+ Used by the plain-env secret strategy: the provisioner writes tokens to
29
+ ``~/.sdxloop/env.sh`` and the worker loads them at startup. Existing
30
+ process environment always wins.
31
+ """
32
+ loaded: dict[str, str] = {}
33
+ if not path.is_file():
34
+ return loaded
35
+ for line in path.read_text().splitlines():
36
+ line = line.strip()
37
+ if not line or line.startswith("#"):
38
+ continue
39
+ match = _EXPORT_RE.match(line)
40
+ if not match:
41
+ continue
42
+ key, raw = match.groups()
43
+ try:
44
+ parts = shlex.split(raw)
45
+ except ValueError:
46
+ continue
47
+ loaded[key] = parts[0] if parts else ""
48
+ return loaded
49
+
50
+
51
+ def apply_env_file(path: Path) -> None:
52
+ for key, value in load_env_file(path).items():
53
+ os.environ.setdefault(key, value)
54
+
55
+
56
+ def main(argv: list[str] | None = None) -> int:
57
+ parser = argparse.ArgumentParser(prog="sdxloop_worker")
58
+ sub = parser.add_subparsers(dest="command", required=True)
59
+ run = sub.add_parser("run", help="run one job")
60
+ run.add_argument("--job", required=True, type=Path)
61
+ run.add_argument("--events", required=True, type=Path)
62
+ run.add_argument("--result", required=True, type=Path)
63
+ run.add_argument("--heartbeat", type=float, default=15.0)
64
+ run.add_argument("--env-file", type=Path, default=DEFAULT_ENV_FILE)
65
+ args = parser.parse_args(argv)
66
+
67
+ apply_env_file(args.env_file)
68
+
69
+ try:
70
+ job = JobRequest.model_validate_json(args.job.read_text())
71
+ except (OSError, ValueError) as exc:
72
+ print(f"sdxloop_worker: invalid job file {args.job}: {exc}", file=sys.stderr)
73
+ return 64
74
+
75
+ try:
76
+ JobRunner(
77
+ job,
78
+ events_path=args.events,
79
+ result_path=args.result,
80
+ heartbeat_s=args.heartbeat,
81
+ ).run()
82
+ except BaseException as exc:
83
+ print(f"sdxloop_worker: fatal: {exc}", file=sys.stderr)
84
+ return 70
85
+ return 0
86
+
87
+
88
+ if __name__ == "__main__":
89
+ sys.exit(main())
@@ -0,0 +1,29 @@
1
+ """Extraction of structured JSON from agent output text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Any
8
+
9
+ _FENCE_RE = re.compile(r"```(?:json)?\s*\n(.*?)```", re.DOTALL)
10
+
11
+
12
+ def extract_json(text: str) -> dict[str, Any] | list[Any] | None:
13
+ """Pull a JSON object/array out of agent output.
14
+
15
+ Preference order: the last fenced ``json`` block (agents often narrate
16
+ before the final answer), then the whole text. Returns None when nothing
17
+ parses to a dict or list.
18
+ """
19
+ candidates = [match.group(1) for match in _FENCE_RE.finditer(text)]
20
+ candidates.reverse()
21
+ candidates.append(text)
22
+ for candidate in candidates:
23
+ try:
24
+ value = json.loads(candidate.strip())
25
+ except json.JSONDecodeError:
26
+ continue
27
+ if isinstance(value, dict | list):
28
+ return value
29
+ return None
@@ -0,0 +1,48 @@
1
+ """Agent backends: the pluggable engine that runs one agent session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Protocol
9
+
10
+ from sdxloop_worker.protocol import Event, JobRequest, Usage
11
+
12
+ # emit("agent.message", content="...") -> Event
13
+ EmitFn = Callable[..., Event]
14
+
15
+ BACKEND_ENV = "SDXLOOP_WORKER_BACKEND"
16
+
17
+
18
+ class BackendUnavailableError(RuntimeError):
19
+ """The requested backend cannot run in this environment."""
20
+
21
+
22
+ @dataclass
23
+ class BackendResult:
24
+ output_text: str = ""
25
+ output_json: dict[str, Any] | list[Any] | None = None
26
+ session_id: str | None = None
27
+ usage: Usage | None = None
28
+ artifacts: list[str] = field(default_factory=list)
29
+
30
+
31
+ class AgentBackend(Protocol):
32
+ name: str
33
+
34
+ def run_session(self, job: JobRequest, emit: EmitFn) -> BackendResult: ...
35
+
36
+
37
+ def get_backend(name: str | None = None) -> AgentBackend:
38
+ """Resolve a backend by name (default: $SDXLOOP_WORKER_BACKEND or copilot)."""
39
+ resolved = name or os.environ.get(BACKEND_ENV) or "copilot"
40
+ if resolved == "echo":
41
+ from sdxloop_worker.backends.echo import EchoBackend
42
+
43
+ return EchoBackend()
44
+ if resolved == "copilot":
45
+ from sdxloop_worker.backends.copilot import CopilotBackend
46
+
47
+ return CopilotBackend()
48
+ raise BackendUnavailableError(f"unknown agent backend {resolved!r}")
@@ -0,0 +1,151 @@
1
+ """GitHub Copilot SDK backend.
2
+
3
+ Runs one agentic session inside the sandbox via the ``github-copilot-sdk``
4
+ package (installed with the ``[copilot]`` extra; wheels bundle the Copilot
5
+ CLI runtime). Imports are deferred so the worker package works without the
6
+ SDK for shell/github jobs and for test environments.
7
+
8
+ Auth: the SDK auto-detects ``COPILOT_GITHUB_TOKEN``, which the sdxloop
9
+ provisioner injects into the agent sandbox (proxy-bound to the Copilot API
10
+ hosts under the default secret strategy).
11
+
12
+ This module is exercised by the real-sbx e2e workflow rather than unit
13
+ tests (it needs the SDK runtime + a Copilot subscription), and is excluded
14
+ from unit coverage accordingly.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ from typing import Any
21
+
22
+ from sdxloop_worker._json import extract_json
23
+ from sdxloop_worker.backends import BackendResult, BackendUnavailableError, EmitFn
24
+ from sdxloop_worker.protocol import EventTypes, JobRequest, Usage
25
+
26
+ READ_ONLY_DENIED_KINDS = {"shell", "write"}
27
+
28
+
29
+ class CopilotBackend:
30
+ name = "copilot"
31
+
32
+ def run_session(self, job: JobRequest, emit: EmitFn) -> BackendResult:
33
+ try:
34
+ import copilot # noqa: F401
35
+ except ImportError as exc:
36
+ raise BackendUnavailableError(
37
+ "github-copilot-sdk is not installed; install sdxloop-worker[copilot]"
38
+ ) from exc
39
+ return asyncio.run(self._run(job, emit))
40
+
41
+ async def _run(self, job: JobRequest, emit: EmitFn) -> BackendResult:
42
+ from copilot import CopilotClient
43
+
44
+ usage = Usage()
45
+ final_text: list[str] = []
46
+
47
+ def on_event(event: Any) -> None:
48
+ nonlocal usage
49
+ data = getattr(event, "data", None)
50
+ type_name = type(data).__name__ if data is not None else type(event).__name__
51
+ if type_name == "AssistantMessageDeltaData":
52
+ emit(
53
+ EventTypes.AGENT_MESSAGE_DELTA,
54
+ delta=getattr(data, "delta_content", "") or "",
55
+ )
56
+ elif type_name == "AssistantMessageData":
57
+ content = getattr(data, "content", "") or ""
58
+ if content:
59
+ final_text.append(content)
60
+ emit(EventTypes.AGENT_MESSAGE, content=content)
61
+ elif type_name.startswith("ToolExecutionStart"):
62
+ emit(
63
+ EventTypes.AGENT_TOOL_START,
64
+ tool=getattr(data, "tool_name", None) or getattr(data, "toolName", None),
65
+ tool_call_id=getattr(data, "tool_call_id", None)
66
+ or getattr(data, "toolCallId", None),
67
+ )
68
+ elif type_name.startswith("ToolExecutionComplete"):
69
+ emit(
70
+ EventTypes.AGENT_TOOL_END,
71
+ tool_call_id=getattr(data, "tool_call_id", None)
72
+ or getattr(data, "toolCallId", None),
73
+ success=getattr(data, "success", None),
74
+ )
75
+ elif type_name == "AssistantUsageData":
76
+ sample = Usage(
77
+ model=getattr(data, "model", None),
78
+ input_tokens=getattr(data, "input_tokens", None)
79
+ or getattr(data, "inputTokens", None),
80
+ output_tokens=getattr(data, "output_tokens", None)
81
+ or getattr(data, "outputTokens", None),
82
+ )
83
+ usage = usage.merged(sample)
84
+ emit(EventTypes.AGENT_USAGE, **sample.model_dump(exclude_none=True))
85
+
86
+ async with CopilotClient() as client:
87
+ session = await self._open_session(client, job)
88
+ try:
89
+ session.on(on_event)
90
+ assert job.prompt is not None
91
+ response = await session.send_and_wait(job.prompt, timeout=job.timeout_s)
92
+ text = self._response_text(response) or "\n".join(final_text)
93
+ output_json = extract_json(text) if job.expect == "json" else None
94
+ session_id = getattr(session, "session_id", None) or getattr(session, "id", None)
95
+ return BackendResult(
96
+ output_text=text,
97
+ output_json=output_json,
98
+ session_id=session_id,
99
+ usage=usage if usage != Usage() else None,
100
+ )
101
+ finally:
102
+ await self._close_session(session)
103
+
104
+ async def _open_session(self, client: Any, job: JobRequest) -> Any:
105
+ kwargs: dict[str, Any] = {
106
+ "on_permission_request": self._permission_handler(job),
107
+ "streaming": True,
108
+ }
109
+ if job.model and job.model != "auto":
110
+ kwargs["model"] = job.model
111
+ if job.system_message:
112
+ kwargs["system_message"] = {"mode": "append", "content": job.system_message}
113
+ if job.resume_session_id:
114
+ return await client.resume_session(job.resume_session_id, **kwargs)
115
+ return await client.create_session(**kwargs)
116
+
117
+ def _permission_handler(self, job: JobRequest) -> Any:
118
+ if job.permission_mode == "auto":
119
+ # The microVM (network policy + secret proxy) is the security
120
+ # boundary; inside it the agent runs unattended.
121
+ from copilot.session import PermissionHandler
122
+
123
+ return PermissionHandler.approve_all
124
+
125
+ def read_only_handler(request: Any, invocation: Any = None) -> Any:
126
+ from copilot.rpc import PermissionDecisionApproveOnce, PermissionDecisionReject
127
+
128
+ kind = getattr(request, "kind", None)
129
+ if kind in READ_ONLY_DENIED_KINDS:
130
+ return PermissionDecisionReject(
131
+ feedback="this is a read-only review session; do not modify anything"
132
+ )
133
+ return PermissionDecisionApproveOnce()
134
+
135
+ return read_only_handler
136
+
137
+ @staticmethod
138
+ def _response_text(response: Any) -> str:
139
+ data = getattr(response, "data", None)
140
+ return getattr(data, "content", None) or getattr(response, "content", None) or ""
141
+
142
+ @staticmethod
143
+ async def _close_session(session: Any) -> None:
144
+ disconnect = getattr(session, "disconnect", None)
145
+ if disconnect is not None:
146
+ try:
147
+ result = disconnect()
148
+ if asyncio.iscoroutine(result):
149
+ await result
150
+ except Exception:
151
+ pass