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.
- sdxloop_worker/__init__.py +10 -0
- sdxloop_worker/__main__.py +89 -0
- sdxloop_worker/_json.py +29 -0
- sdxloop_worker/backends/__init__.py +48 -0
- sdxloop_worker/backends/copilot.py +151 -0
- sdxloop_worker/backends/echo.py +89 -0
- sdxloop_worker/events.py +61 -0
- sdxloop_worker/githubops.py +245 -0
- sdxloop_worker/protocol.py +191 -0
- sdxloop_worker/py.typed +0 -0
- sdxloop_worker/runner.py +160 -0
- sdxloop_worker-0.1.0.dist-info/METADATA +33 -0
- sdxloop_worker-0.1.0.dist-info/RECORD +14 -0
- sdxloop_worker-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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())
|
sdxloop_worker/_json.py
ADDED
|
@@ -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
|
+
)
|
sdxloop_worker/events.py
ADDED
|
@@ -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"
|
sdxloop_worker/py.typed
ADDED
|
File without changes
|
sdxloop_worker/runner.py
ADDED
|
@@ -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,,
|