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.
- sdxloop_worker-0.1.0/.gitignore +222 -0
- sdxloop_worker-0.1.0/PKG-INFO +33 -0
- sdxloop_worker-0.1.0/README.md +12 -0
- sdxloop_worker-0.1.0/pyproject.toml +32 -0
- sdxloop_worker-0.1.0/src/sdxloop_worker/__init__.py +10 -0
- sdxloop_worker-0.1.0/src/sdxloop_worker/__main__.py +89 -0
- sdxloop_worker-0.1.0/src/sdxloop_worker/_json.py +29 -0
- sdxloop_worker-0.1.0/src/sdxloop_worker/backends/__init__.py +48 -0
- sdxloop_worker-0.1.0/src/sdxloop_worker/backends/copilot.py +151 -0
- sdxloop_worker-0.1.0/src/sdxloop_worker/backends/echo.py +89 -0
- sdxloop_worker-0.1.0/src/sdxloop_worker/events.py +61 -0
- sdxloop_worker-0.1.0/src/sdxloop_worker/githubops.py +245 -0
- sdxloop_worker-0.1.0/src/sdxloop_worker/protocol.py +191 -0
- sdxloop_worker-0.1.0/src/sdxloop_worker/py.typed +0 -0
- sdxloop_worker-0.1.0/src/sdxloop_worker/runner.py +160 -0
- sdxloop_worker-0.1.0/tests/conftest.py +11 -0
- sdxloop_worker-0.1.0/tests/test_githubops.py +276 -0
- sdxloop_worker-0.1.0/tests/test_json_extract.py +37 -0
- sdxloop_worker-0.1.0/tests/test_protocol.py +131 -0
- sdxloop_worker-0.1.0/tests/test_runner_unit.py +184 -0
- sdxloop_worker-0.1.0/tests/test_worker_contract.py +223 -0
- sdxloop_worker-0.1.0/tests/worker_harness.py +73 -0
|
@@ -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
|