sdxloop-worker 0.1.0__py3-none-any.whl

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,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
@@ -0,0 +1,89 @@
1
+ """Deterministic test backend.
2
+
3
+ Without a script it echoes the prompt. With ``SDXLOOP_ECHO_SCRIPT`` pointing
4
+ at a JSON file (a list of response objects), responses are consumed in order
5
+ — across worker processes — via a sidecar ``<script>.state`` cursor file, so
6
+ multi-job engine tests can script an entire run.
7
+
8
+ Response object shape (all fields optional):
9
+ ``{"text": str, "json": dict|list, "session_id": str, "sleep_s": float,
10
+ "events": [{"type": str, "data": {...}}], "fail": str}``
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import time
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from sdxloop_worker._json import extract_json
22
+ from sdxloop_worker.backends import BackendResult, EmitFn
23
+ from sdxloop_worker.protocol import EventTypes, JobRequest, Usage
24
+
25
+ SCRIPT_ENV = "SDXLOOP_ECHO_SCRIPT"
26
+
27
+
28
+ class EchoBackend:
29
+ name = "echo"
30
+
31
+ def run_session(self, job: JobRequest, emit: EmitFn) -> BackendResult:
32
+ scripted = self._next_scripted_response()
33
+ if scripted is None:
34
+ return self._echo(job, emit)
35
+ return self._scripted(job, emit, scripted)
36
+
37
+ # -- default echo mode -------------------------------------------------
38
+
39
+ def _echo(self, job: JobRequest, emit: EmitFn) -> BackendResult:
40
+ assert job.prompt is not None
41
+ text = f"echo: {job.prompt}"
42
+ emit(EventTypes.AGENT_MESSAGE, content=text)
43
+ output_json = {"echo": job.prompt} if job.expect == "json" else None
44
+ return BackendResult(
45
+ output_text=text,
46
+ output_json=output_json,
47
+ session_id=f"echo-{job.job_id}",
48
+ usage=Usage(model="echo", input_tokens=len(job.prompt.split()), output_tokens=2),
49
+ )
50
+
51
+ # -- scripted mode -----------------------------------------------------
52
+
53
+ def _next_scripted_response(self) -> dict[str, Any] | None:
54
+ raw = os.environ.get(SCRIPT_ENV)
55
+ if not raw:
56
+ return None
57
+ script_path = Path(raw)
58
+ responses = json.loads(script_path.read_text())
59
+ state_path = script_path.with_suffix(script_path.suffix + ".state")
60
+ cursor = int(state_path.read_text()) if state_path.is_file() else 0
61
+ if cursor >= len(responses):
62
+ raise RuntimeError(
63
+ f"echo script exhausted: {cursor} responses consumed, job wanted one more"
64
+ )
65
+ state_path.write_text(str(cursor + 1))
66
+ response = responses[cursor]
67
+ if not isinstance(response, dict):
68
+ raise TypeError(f"echo script entry {cursor} must be an object")
69
+ return response
70
+
71
+ def _scripted(self, job: JobRequest, emit: EmitFn, response: dict[str, Any]) -> BackendResult:
72
+ if "fail" in response:
73
+ raise RuntimeError(str(response["fail"]))
74
+ if sleep_s := float(response.get("sleep_s", 0)):
75
+ time.sleep(sleep_s)
76
+ for scripted_event in response.get("events", []):
77
+ emit(scripted_event["type"], **scripted_event.get("data", {}))
78
+ text = str(response.get("text", ""))
79
+ if text:
80
+ emit(EventTypes.AGENT_MESSAGE, content=text)
81
+ output_json = response.get("json")
82
+ if output_json is None and job.expect == "json":
83
+ output_json = extract_json(text)
84
+ return BackendResult(
85
+ output_text=text,
86
+ output_json=output_json,
87
+ session_id=str(response.get("session_id", f"echo-{job.job_id}")),
88
+ usage=Usage(model="echo"),
89
+ )
@@ -0,0 +1,61 @@
1
+ """Worker-side event emission: JSONL file (durable) + stdout mirror (live)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from types import TracebackType
9
+ from typing import Any, TextIO
10
+
11
+ from sdxloop_worker.protocol import Event
12
+
13
+
14
+ class EventWriter:
15
+ """Appends events to a JSONL file (fsync per line) and mirrors to stdout.
16
+
17
+ The file is the durable record the host can always recover with ``cp``;
18
+ the stdout mirror is the live telemetry channel when the host streams
19
+ ``sbx exec`` output. Either channel may be lost independently — the
20
+ result file, not events, is the source of truth for job outcomes.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ path: Path,
26
+ run_id: str,
27
+ job_id: str | None = None,
28
+ mirror: TextIO | None = None,
29
+ ) -> None:
30
+ self.run_id = run_id
31
+ self.job_id = job_id
32
+ path.parent.mkdir(parents=True, exist_ok=True)
33
+ self._file: TextIO | None = path.open("a", encoding="utf-8")
34
+ self._mirror = sys.stdout if mirror is None else mirror
35
+
36
+ def emit(self, type: str, **data: Any) -> Event:
37
+ event = Event.now(type, self.run_id, job_id=self.job_id, **data)
38
+ line = event.to_json_line()
39
+ if self._file is not None:
40
+ self._file.write(line + "\n")
41
+ self._file.flush()
42
+ os.fsync(self._file.fileno())
43
+ if self._mirror is not None:
44
+ print(line, file=self._mirror, flush=True)
45
+ return event
46
+
47
+ def close(self) -> None:
48
+ if self._file is not None:
49
+ self._file.close()
50
+ self._file = None
51
+
52
+ def __enter__(self) -> EventWriter:
53
+ return self
54
+
55
+ def __exit__(
56
+ self,
57
+ exc_type: type[BaseException] | None,
58
+ exc: BaseException | None,
59
+ tb: TracebackType | None,
60
+ ) -> None:
61
+ self.close()
@@ -0,0 +1,245 @@
1
+ """GitHub operations executed inside the github-ops sandbox.
2
+
3
+ Every operation is expressed as a GitHub REST call and executed through one
4
+ of two transports:
5
+
6
+ - **gh CLI** (preferred when present): ``gh api`` — inherits `GH_TOKEN`
7
+ handling, retries, and pagination behavior from gh.
8
+ - **urllib REST** (mandatory fallback): the sandbox `shell` template does not
9
+ guarantee gh, so a pure-stdlib client using ``GH_TOKEN``/``GITHUB_TOKEN``
10
+ directly is required, not optional polish.
11
+
12
+ The op registry maps stable sdxloop op names (``issue.create``, ...) to
13
+ request builders + response shapers, so both transports produce identical
14
+ results for the host.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import json
21
+ import os
22
+ import shutil
23
+ import subprocess
24
+ import urllib.error
25
+ import urllib.parse
26
+ import urllib.request
27
+ from collections.abc import Callable
28
+ from typing import Any, Protocol
29
+
30
+ API_ROOT = "https://api.github.com"
31
+ API_VERSION = "2022-11-28"
32
+
33
+ JsonValue = dict[str, Any] | list[Any]
34
+
35
+
36
+ class GithubOpError(RuntimeError):
37
+ """A GitHub operation failed (bad op, transport error, HTTP error)."""
38
+
39
+
40
+ class Transport(Protocol):
41
+ def request(self, method: str, path: str, body: dict[str, Any] | None = None) -> JsonValue: ...
42
+
43
+
44
+ class GhCliTransport:
45
+ """Executes REST calls through ``gh api``."""
46
+
47
+ def __init__(self, gh: str = "gh") -> None:
48
+ self.gh = gh
49
+
50
+ def request(self, method: str, path: str, body: dict[str, Any] | None = None) -> JsonValue:
51
+ argv = [
52
+ self.gh,
53
+ "api",
54
+ "-X",
55
+ method,
56
+ "-H",
57
+ f"X-GitHub-Api-Version: {API_VERSION}",
58
+ path,
59
+ ]
60
+ stdin: str | None = None
61
+ if body is not None:
62
+ argv += ["--input", "-"]
63
+ stdin = json.dumps(body)
64
+ proc = subprocess.run(
65
+ argv, capture_output=True, text=True, input=stdin, timeout=120, check=False
66
+ )
67
+ if proc.returncode != 0:
68
+ raise GithubOpError(
69
+ f"gh api {method} {path} failed (rc={proc.returncode}): "
70
+ f"{proc.stderr.strip()[:2000]}"
71
+ )
72
+ if not proc.stdout.strip():
73
+ return {}
74
+ parsed: JsonValue = json.loads(proc.stdout)
75
+ return parsed
76
+
77
+
78
+ class RestTransport:
79
+ """Pure-stdlib GitHub REST client using the injected token."""
80
+
81
+ def __init__(self, token: str | None = None) -> None:
82
+ self.token = token or os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
83
+ if not self.token:
84
+ raise GithubOpError(
85
+ "no GitHub token available: GH_TOKEN/GITHUB_TOKEN are not set and gh is absent"
86
+ )
87
+
88
+ def request(self, method: str, path: str, body: dict[str, Any] | None = None) -> JsonValue:
89
+ url = path if path.startswith("http") else f"{API_ROOT}{path}"
90
+ data = json.dumps(body).encode() if body is not None else None
91
+ request = urllib.request.Request(
92
+ url,
93
+ data=data,
94
+ method=method,
95
+ headers={
96
+ "Authorization": f"Bearer {self.token}",
97
+ "Accept": "application/vnd.github+json",
98
+ "X-GitHub-Api-Version": API_VERSION,
99
+ "User-Agent": "sdxloop-worker",
100
+ **({"Content-Type": "application/json"} if data else {}),
101
+ },
102
+ )
103
+ try:
104
+ with urllib.request.urlopen(request, timeout=60) as response:
105
+ raw = response.read().decode()
106
+ except urllib.error.HTTPError as exc:
107
+ detail = exc.read().decode(errors="replace")[:2000]
108
+ raise GithubOpError(f"{method} {url} -> HTTP {exc.code}: {detail}") from exc
109
+ except urllib.error.URLError as exc:
110
+ raise GithubOpError(f"{method} {url} failed: {exc.reason}") from exc
111
+ if not raw.strip():
112
+ return {}
113
+ parsed: JsonValue = json.loads(raw)
114
+ return parsed
115
+
116
+
117
+ def select_transport() -> Transport:
118
+ if shutil.which("gh"):
119
+ return GhCliTransport()
120
+ return RestTransport()
121
+
122
+
123
+ # -- op registry -------------------------------------------------------------
124
+
125
+ OpImpl = Callable[[Transport, dict[str, Any]], JsonValue]
126
+
127
+
128
+ def _require(params: dict[str, Any], *names: str) -> None:
129
+ missing = [n for n in names if not params.get(n)]
130
+ if missing:
131
+ raise GithubOpError(f"missing required params: {', '.join(missing)}")
132
+
133
+
134
+ def _issue_create(t: Transport, p: dict[str, Any]) -> JsonValue:
135
+ _require(p, "repo", "title")
136
+ body: dict[str, Any] = {"title": p["title"]}
137
+ if p.get("body"):
138
+ body["body"] = p["body"]
139
+ if p.get("labels"):
140
+ body["labels"] = p["labels"]
141
+ data = t.request("POST", f"/repos/{p['repo']}/issues", body)
142
+ assert isinstance(data, dict)
143
+ return {"number": data.get("number"), "url": data.get("html_url")}
144
+
145
+
146
+ def _issue_comment(t: Transport, p: dict[str, Any]) -> JsonValue:
147
+ _require(p, "repo", "number", "body")
148
+ data = t.request(
149
+ "POST", f"/repos/{p['repo']}/issues/{p['number']}/comments", {"body": p["body"]}
150
+ )
151
+ assert isinstance(data, dict)
152
+ return {"url": data.get("html_url")}
153
+
154
+
155
+ def _pr_create(t: Transport, p: dict[str, Any]) -> JsonValue:
156
+ _require(p, "repo", "base", "head", "title")
157
+ body: dict[str, Any] = {"title": p["title"], "base": p["base"], "head": p["head"]}
158
+ if p.get("body"):
159
+ body["body"] = p["body"]
160
+ if p.get("draft"):
161
+ body["draft"] = True
162
+ data = t.request("POST", f"/repos/{p['repo']}/pulls", body)
163
+ assert isinstance(data, dict)
164
+ return {"number": data.get("number"), "url": data.get("html_url")}
165
+
166
+
167
+ def _contents_read(t: Transport, p: dict[str, Any]) -> JsonValue:
168
+ _require(p, "repo", "path")
169
+ path = f"/repos/{p['repo']}/contents/{p['path']}"
170
+ if p.get("ref"):
171
+ path += f"?ref={urllib.parse.quote(str(p['ref']))}"
172
+ data = t.request("GET", path)
173
+ assert isinstance(data, dict)
174
+ result: dict[str, Any] = {"path": data.get("path"), "sha": data.get("sha")}
175
+ if data.get("encoding") == "base64" and isinstance(data.get("content"), str):
176
+ raw = base64.b64decode(data["content"])
177
+ try:
178
+ result["content"] = raw.decode("utf-8")
179
+ result["binary"] = False
180
+ except UnicodeDecodeError:
181
+ result["content"] = base64.b64encode(raw).decode()
182
+ result["binary"] = True
183
+ else:
184
+ result["content"] = data.get("content")
185
+ result["binary"] = False
186
+ return result
187
+
188
+
189
+ def _status_create(t: Transport, p: dict[str, Any]) -> JsonValue:
190
+ _require(p, "repo", "sha", "state")
191
+ body = {
192
+ "state": p["state"],
193
+ "context": p.get("context", "sdxloop"),
194
+ }
195
+ if p.get("description"):
196
+ body["description"] = p["description"]
197
+ if p.get("target_url"):
198
+ body["target_url"] = p["target_url"]
199
+ data = t.request("POST", f"/repos/{p['repo']}/statuses/{p['sha']}", body)
200
+ assert isinstance(data, dict)
201
+ return {"id": data.get("id"), "state": data.get("state")}
202
+
203
+
204
+ def _repo_get(t: Transport, p: dict[str, Any]) -> JsonValue:
205
+ _require(p, "repo")
206
+ return t.request("GET", f"/repos/{p['repo']}")
207
+
208
+
209
+ def _search_issues(t: Transport, p: dict[str, Any]) -> JsonValue:
210
+ _require(p, "query")
211
+ query = urllib.parse.quote(str(p["query"]))
212
+ per_page = int(p.get("per_page", 30))
213
+ data = t.request("GET", f"/search/issues?q={query}&per_page={per_page}")
214
+ assert isinstance(data, dict)
215
+ items = data.get("items", [])
216
+ return items if isinstance(items, list) else []
217
+
218
+
219
+ def _raw_api(t: Transport, p: dict[str, Any]) -> JsonValue:
220
+ _require(p, "method", "path")
221
+ return t.request(str(p["method"]).upper(), str(p["path"]), p.get("body"))
222
+
223
+
224
+ OPS: dict[str, OpImpl] = {
225
+ "issue.create": _issue_create,
226
+ "issue.comment": _issue_comment,
227
+ "pr.create": _pr_create,
228
+ "pr.comment": _issue_comment, # PR comments go through the issues API
229
+ "contents.read": _contents_read,
230
+ "status.create": _status_create,
231
+ "repo.get": _repo_get,
232
+ "search.issues": _search_issues,
233
+ "raw.api": _raw_api,
234
+ }
235
+
236
+
237
+ def execute_op(
238
+ op: str,
239
+ params: dict[str, Any],
240
+ transport: Transport | None = None,
241
+ ) -> JsonValue:
242
+ impl = OPS.get(op)
243
+ if impl is None:
244
+ raise GithubOpError(f"unknown github op {op!r}; known: {', '.join(sorted(OPS))}")
245
+ return impl(transport or select_transport(), params)
@@ -0,0 +1,191 @@
1
+ """Shared host/worker protocol models.
2
+
3
+ These models are the contract between the sdxloop host orchestrator and the
4
+ in-sandbox worker. Both sides import this exact module (the host depends on
5
+ ``sdxloop-worker``), so drift is a type error rather than a runtime surprise.
6
+
7
+ Every object carries a protocol version ``v``; host and worker versions are
8
+ kept in lockstep, so models reject unknown fields outright.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import time
15
+ from typing import Any, Literal
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
18
+
19
+ PROTOCOL_VERSION = 1
20
+
21
+ JobKind = Literal["agent.session", "shell.check", "github.op"]
22
+ JobStatus = Literal["ok", "error", "timeout"]
23
+ PermissionMode = Literal["auto", "read_only"]
24
+ ExpectMode = Literal["text", "json"]
25
+
26
+
27
+ class ProtocolModel(BaseModel):
28
+ """Base for all wire models: strict, no silent extra fields."""
29
+
30
+ model_config = ConfigDict(extra="forbid")
31
+
32
+
33
+ class Usage(ProtocolModel):
34
+ """Model/token usage reported by an agent backend for one job."""
35
+
36
+ model: str | None = None
37
+ input_tokens: int | None = None
38
+ output_tokens: int | None = None
39
+ cache_read_tokens: int | None = None
40
+ cache_write_tokens: int | None = None
41
+ cost: float | None = None
42
+
43
+ def merged(self, other: Usage) -> Usage:
44
+ """Accumulate another usage sample into this one (None-safe sums)."""
45
+
46
+ def add(a: int | None, b: int | None) -> int | None:
47
+ if a is None and b is None:
48
+ return None
49
+ return (a or 0) + (b or 0)
50
+
51
+ cost: float | None = None
52
+ if self.cost is not None or other.cost is not None:
53
+ cost = (self.cost or 0.0) + (other.cost or 0.0)
54
+ return Usage(
55
+ model=other.model or self.model,
56
+ input_tokens=add(self.input_tokens, other.input_tokens),
57
+ output_tokens=add(self.output_tokens, other.output_tokens),
58
+ cache_read_tokens=add(self.cache_read_tokens, other.cache_read_tokens),
59
+ cache_write_tokens=add(self.cache_write_tokens, other.cache_write_tokens),
60
+ cost=cost,
61
+ )
62
+
63
+
64
+ class ErrorInfo(ProtocolModel):
65
+ """Structured error carried in a JobResult or error event."""
66
+
67
+ type: str
68
+ message: str
69
+ detail: str | None = None
70
+
71
+
72
+ class JobRequest(ProtocolModel):
73
+ """One unit of work the host submits to a worker.
74
+
75
+ Exactly one job kind is used per request; kind-specific fields for other
76
+ kinds must be left at their defaults (validated below).
77
+ """
78
+
79
+ v: int = PROTOCOL_VERSION
80
+ job_id: str
81
+ run_id: str
82
+ kind: JobKind
83
+ timeout_s: float = 900.0
84
+
85
+ # kind == "agent.session"
86
+ prompt: str | None = None
87
+ system_message: str | None = None
88
+ model: str | None = None
89
+ resume_session_id: str | None = None
90
+ permission_mode: PermissionMode = "auto"
91
+ expect: ExpectMode = "text"
92
+
93
+ # kind == "shell.check"
94
+ argv: list[str] | None = None
95
+ cwd: str | None = None
96
+
97
+ # kind == "github.op"
98
+ op: str | None = None
99
+ params: dict[str, Any] = Field(default_factory=dict)
100
+
101
+ @model_validator(mode="after")
102
+ def _check_kind_fields(self) -> JobRequest:
103
+ if self.kind == "agent.session":
104
+ if not self.prompt:
105
+ raise ValueError("agent.session requires a non-empty prompt")
106
+ if self.argv is not None or self.op is not None:
107
+ raise ValueError("agent.session must not set argv or op")
108
+ elif self.kind == "shell.check":
109
+ if not self.argv:
110
+ raise ValueError("shell.check requires a non-empty argv")
111
+ if self.prompt is not None or self.op is not None:
112
+ raise ValueError("shell.check must not set prompt or op")
113
+ elif self.kind == "github.op":
114
+ if not self.op:
115
+ raise ValueError("github.op requires an op name")
116
+ if self.prompt is not None or self.argv is not None:
117
+ raise ValueError("github.op must not set prompt or argv")
118
+ return self
119
+
120
+
121
+ class JobResult(ProtocolModel):
122
+ """Authoritative outcome of a job, written to the result file by the worker.
123
+
124
+ The event stream is best-effort telemetry; this file is the source of truth.
125
+ """
126
+
127
+ v: int = PROTOCOL_VERSION
128
+ job_id: str
129
+ status: JobStatus
130
+ output_text: str | None = None
131
+ output_json: dict[str, Any] | list[Any] | None = None
132
+ session_id: str | None = None
133
+ usage: Usage | None = None
134
+ exit_code: int | None = None
135
+ error: ErrorInfo | None = None
136
+ artifacts: list[str] = Field(default_factory=list)
137
+
138
+ @model_validator(mode="after")
139
+ def _check_status(self) -> JobResult:
140
+ if self.status != "ok" and self.error is None:
141
+ raise ValueError(f"status={self.status!r} requires an error")
142
+ return self
143
+
144
+
145
+ class Event(ProtocolModel):
146
+ """One line of the JSONL event stream (worker- or host-emitted)."""
147
+
148
+ v: int = PROTOCOL_VERSION
149
+ ts: float
150
+ run_id: str
151
+ job_id: str | None = None
152
+ type: str
153
+ data: dict[str, Any] = Field(default_factory=dict)
154
+
155
+ @classmethod
156
+ def now(
157
+ cls,
158
+ type: str,
159
+ run_id: str,
160
+ job_id: str | None = None,
161
+ **data: Any,
162
+ ) -> Event:
163
+ return cls(ts=time.time(), run_id=run_id, job_id=job_id, type=type, data=data)
164
+
165
+ def to_json_line(self) -> str:
166
+ """Serialize to a single JSONL line (no trailing newline)."""
167
+ return json.dumps(self.model_dump(mode="json"), separators=(",", ":"), sort_keys=True)
168
+
169
+ @classmethod
170
+ def from_json_line(cls, line: str) -> Event:
171
+ return cls.model_validate(json.loads(line))
172
+
173
+
174
+ # Well-known event types. Kept as plain strings on the wire; these constants
175
+ # exist so emitters and consumers share one spelling.
176
+ class EventTypes:
177
+ WORKER_START = "worker.start"
178
+ WORKER_HEARTBEAT = "worker.heartbeat"
179
+ WORKER_STDOUT = "worker.stdout"
180
+ WORKER_RESULT = "worker.result"
181
+ WORKER_ERROR = "worker.error"
182
+ WORKER_END = "worker.end"
183
+
184
+ AGENT_MESSAGE = "agent.message"
185
+ AGENT_MESSAGE_DELTA = "agent.message_delta"
186
+ AGENT_TOOL_START = "agent.tool_start"
187
+ AGENT_TOOL_END = "agent.tool_end"
188
+ AGENT_USAGE = "agent.usage"
189
+
190
+ GH_OP_START = "gh.op_start"
191
+ GH_OP_END = "gh.op_end"
File without changes
@@ -0,0 +1,160 @@
1
+ """The worker job runner: dispatch one JobRequest, emit events, write the result."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import threading
7
+ import traceback
8
+ from pathlib import Path
9
+
10
+ from sdxloop_worker.backends import get_backend
11
+ from sdxloop_worker.events import EventWriter
12
+ from sdxloop_worker.protocol import ErrorInfo, EventTypes, JobRequest, JobResult
13
+
14
+ OUTPUT_TAIL_CHARS = 20_000
15
+
16
+
17
+ class JobRunner:
18
+ def __init__(
19
+ self,
20
+ job: JobRequest,
21
+ events_path: Path,
22
+ result_path: Path,
23
+ *,
24
+ heartbeat_s: float = 15.0,
25
+ backend_name: str | None = None,
26
+ ) -> None:
27
+ self.job = job
28
+ self.events_path = events_path
29
+ self.result_path = result_path
30
+ self.heartbeat_s = heartbeat_s
31
+ self.backend_name = backend_name
32
+
33
+ def run(self) -> JobResult:
34
+ """Execute the job and write the authoritative result file.
35
+
36
+ Never raises for job-level failures — they become error results.
37
+ """
38
+ with EventWriter(self.events_path, self.job.run_id, self.job.job_id) as writer:
39
+ heartbeat_stop = self._start_heartbeat(writer)
40
+ try:
41
+ writer.emit(EventTypes.WORKER_START, kind=self.job.kind)
42
+ result = self._dispatch(writer)
43
+ except subprocess.TimeoutExpired:
44
+ result = self._error_result("timeout", "Timeout", "job timed out")
45
+ except BaseException as exc:
46
+ result = self._error_result(
47
+ "error",
48
+ type(exc).__name__,
49
+ str(exc) or repr(exc),
50
+ detail="".join(traceback.format_exception(exc))[-OUTPUT_TAIL_CHARS:],
51
+ )
52
+ finally:
53
+ heartbeat_stop.set()
54
+
55
+ self.result_path.parent.mkdir(parents=True, exist_ok=True)
56
+ self.result_path.write_text(result.model_dump_json())
57
+ if result.status == "ok":
58
+ writer.emit(EventTypes.WORKER_RESULT, status=result.status)
59
+ else:
60
+ assert result.error is not None
61
+ writer.emit(
62
+ EventTypes.WORKER_ERROR,
63
+ status=result.status,
64
+ error_type=result.error.type,
65
+ message=result.error.message,
66
+ )
67
+ writer.emit(EventTypes.WORKER_END)
68
+ return result
69
+
70
+ # -- dispatch ----------------------------------------------------------
71
+
72
+ def _dispatch(self, writer: EventWriter) -> JobResult:
73
+ if self.job.kind == "agent.session":
74
+ return self._run_agent_session(writer)
75
+ if self.job.kind == "shell.check":
76
+ return self._run_shell_check()
77
+ return self._run_github_op(writer)
78
+
79
+ def _run_agent_session(self, writer: EventWriter) -> JobResult:
80
+ backend = get_backend(self.backend_name)
81
+ outcome = backend.run_session(self.job, writer.emit)
82
+ if self.job.expect == "json" and outcome.output_json is None:
83
+ return self._error_result(
84
+ "error",
85
+ "ExpectedJsonMissing",
86
+ "agent response contained no parseable JSON",
87
+ detail=outcome.output_text[-OUTPUT_TAIL_CHARS:],
88
+ )
89
+ return JobResult(
90
+ job_id=self.job.job_id,
91
+ status="ok",
92
+ output_text=outcome.output_text,
93
+ output_json=outcome.output_json,
94
+ session_id=outcome.session_id,
95
+ usage=outcome.usage,
96
+ artifacts=outcome.artifacts,
97
+ )
98
+
99
+ def _run_shell_check(self) -> JobResult:
100
+ assert self.job.argv is not None
101
+ proc = subprocess.run(
102
+ self.job.argv,
103
+ capture_output=True,
104
+ text=True,
105
+ cwd=self.job.cwd,
106
+ timeout=self.job.timeout_s,
107
+ check=False,
108
+ )
109
+ output = proc.stdout + (("\n" + proc.stderr) if proc.stderr else "")
110
+ # A nonzero inner exit code is still a successful *job*: the engine
111
+ # inspects exit_code to decide whether verification passed.
112
+ return JobResult(
113
+ job_id=self.job.job_id,
114
+ status="ok",
115
+ exit_code=proc.returncode,
116
+ output_text=output[-OUTPUT_TAIL_CHARS:],
117
+ )
118
+
119
+ def _run_github_op(self, writer: EventWriter) -> JobResult:
120
+ from sdxloop_worker.githubops import execute_op
121
+
122
+ assert self.job.op is not None
123
+ writer.emit(EventTypes.GH_OP_START, op=self.job.op)
124
+ output = execute_op(self.job.op, self.job.params)
125
+ writer.emit(EventTypes.GH_OP_END, op=self.job.op)
126
+ return JobResult(job_id=self.job.job_id, status="ok", output_json=output)
127
+
128
+ # -- helpers -----------------------------------------------------------
129
+
130
+ def _error_result(
131
+ self,
132
+ status: str,
133
+ type_: str,
134
+ message: str,
135
+ detail: str | None = None,
136
+ ) -> JobResult:
137
+ return JobResult.model_validate(
138
+ {
139
+ "job_id": self.job.job_id,
140
+ "status": status,
141
+ "error": ErrorInfo(type=type_, message=message, detail=detail).model_dump(),
142
+ }
143
+ )
144
+
145
+ def _start_heartbeat(self, writer: EventWriter) -> threading.Event:
146
+ stop = threading.Event()
147
+ if self.heartbeat_s <= 0:
148
+ stop.set()
149
+ return stop
150
+
151
+ def beat() -> None:
152
+ while not stop.wait(self.heartbeat_s):
153
+ try:
154
+ writer.emit(EventTypes.WORKER_HEARTBEAT)
155
+ except Exception: # pragma: no cover - writer closed during shutdown
156
+ return
157
+
158
+ thread = threading.Thread(target=beat, name="sdxloop-heartbeat", daemon=True)
159
+ thread.start()
160
+ return stop
@@ -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,14 @@
1
+ sdxloop_worker/__init__.py,sha256=jW5aIvWS4rTqv3rHAdLJ-ZHl0hMRw7alxYJA-uaq-sU,299
2
+ sdxloop_worker/__main__.py,sha256=Ph5WXKZnJ6sfrUp4vHKtHlrGAPbW9dAsCalSPdsNHDY,2765
3
+ sdxloop_worker/_json.py,sha256=jobk9AMqZOSxzs2n14SPutv6uJfctZ2f6f69bdw-oyg,882
4
+ sdxloop_worker/events.py,sha256=JmVQLCaoobhM4QfyqJ7gE95MI0cfyKyzIC1xs4Bol2E,1875
5
+ sdxloop_worker/githubops.py,sha256=Xm5HROeeSc8d7s_fJIWCkIEuyevuk35QW_yNA22YezE,8382
6
+ sdxloop_worker/protocol.py,sha256=2eFWcBp0Z25Byi5QKN05XpMbkfYI4fY11Oa_b10n72g,6237
7
+ sdxloop_worker/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ sdxloop_worker/runner.py,sha256=o4-lIJRjA493Gw0NBhHXSf-B47akXtf6MCnqMBQZQak,5778
9
+ sdxloop_worker/backends/__init__.py,sha256=7X8_IjrHMN8Khe5pd3MlNGR4zJFHwXIjcRXVgBNk1vs,1414
10
+ sdxloop_worker/backends/copilot.py,sha256=u7lOIX4nIg2CqHNe_FZ6AaEI8XCw8Z7YZiexbE2dij8,6392
11
+ sdxloop_worker/backends/echo.py,sha256=jLJK_SNnxjA0zB0Yoev81hL-pdK7zbW4ElO6O8IDFDk,3471
12
+ sdxloop_worker-0.1.0.dist-info/METADATA,sha256=7zVXOelFmR6DzGZisE-MfTNopZgqpUZFvgiTNkVQv-E,1238
13
+ sdxloop_worker-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
14
+ sdxloop_worker-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any