sbxloop-worker 0.2.0__tar.gz → 0.4.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.
Files changed (22) hide show
  1. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/PKG-INFO +1 -1
  2. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/pyproject.toml +1 -1
  3. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/src/sbxloop_worker/__init__.py +1 -1
  4. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/src/sbxloop_worker/__main__.py +13 -0
  5. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/src/sbxloop_worker/backends/copilot.py +84 -5
  6. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/src/sbxloop_worker/backends/echo.py +9 -1
  7. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/src/sbxloop_worker/protocol.py +4 -0
  8. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/.gitignore +0 -0
  9. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/README.md +0 -0
  10. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/src/sbxloop_worker/_json.py +0 -0
  11. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/src/sbxloop_worker/backends/__init__.py +0 -0
  12. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/src/sbxloop_worker/events.py +0 -0
  13. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/src/sbxloop_worker/githubops.py +0 -0
  14. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/src/sbxloop_worker/py.typed +0 -0
  15. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/src/sbxloop_worker/runner.py +0 -0
  16. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/tests/conftest.py +0 -0
  17. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/tests/test_githubops.py +0 -0
  18. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/tests/test_json_extract.py +0 -0
  19. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/tests/test_protocol.py +0 -0
  20. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/tests/test_runner_unit.py +0 -0
  21. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/tests/test_worker_contract.py +0 -0
  22. {sbxloop_worker-0.2.0 → sbxloop_worker-0.4.0}/tests/worker_harness.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sbxloop-worker
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: In-sandbox worker runtime for sbxloop (protocol models, agent backends, job runner)
5
5
  Project-URL: Homepage, https://github.com/brettbergin/sbxloop
6
6
  Project-URL: Repository, https://github.com/brettbergin/sbxloop
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sbxloop-worker"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "In-sandbox worker runtime for sbxloop (protocol models, agent backends, job runner)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -5,6 +5,6 @@ models, the agent backends (GitHub Copilot SDK), and the job runner invoked
5
5
  via ``python -m sbxloop_worker``.
6
6
  """
7
7
 
8
- __version__ = "0.2.0"
8
+ __version__ = "0.4.0"
9
9
 
10
10
  __all__ = ["__version__"]
@@ -65,6 +65,7 @@ def main(argv: list[str] | None = None) -> int:
65
65
  run.add_argument("--result", required=True, type=Path)
66
66
  run.add_argument("--heartbeat", type=float, default=15.0)
67
67
  run.add_argument("--env-file", type=Path, default=DEFAULT_ENV_FILE)
68
+ run.add_argument("--cwd", type=Path, default=None)
68
69
  args = parser.parse_args(argv)
69
70
 
70
71
  apply_env_file(args.env_file)
@@ -76,6 +77,18 @@ def main(argv: list[str] | None = None) -> int:
76
77
  print(f"sbxloop_worker: invalid job file {args.job}: {exc}", file=sys.stderr)
77
78
  return 64
78
79
 
80
+ if args.cwd is not None:
81
+ # Run the whole worker in the job's working directory so agent
82
+ # sessions (which inherit the process cwd) and shell commands both
83
+ # execute in the run workspace. Overwrite job.cwd with the resolved
84
+ # value so shell.check keeps its single code path.
85
+ try:
86
+ os.chdir(args.cwd)
87
+ except OSError as exc:
88
+ print(f"sbxloop_worker: cannot chdir to {args.cwd}: {exc}", file=sys.stderr)
89
+ return 64
90
+ job = job.model_copy(update={"cwd": str(Path.cwd())})
91
+
79
92
  try:
80
93
  JobRunner(
81
94
  job,
@@ -17,6 +17,7 @@ from unit coverage accordingly.
17
17
  from __future__ import annotations
18
18
 
19
19
  import asyncio
20
+ import json
20
21
  import os
21
22
  from typing import Any
22
23
 
@@ -52,6 +53,59 @@ def _auth_diagnostic() -> str:
52
53
  )
53
54
 
54
55
 
56
+ # Transcript-facing clips: the event stream is telemetry, not the artifact
57
+ # channel, so payloads are bounded aggressively.
58
+ TOOL_ARGS_CLIP = 400
59
+ TOOL_OUTPUT_CLIP = 1_000
60
+
61
+
62
+ def _tool_args(arguments: Any) -> str | None:
63
+ """The tool's arguments as one displayable string (best-effort).
64
+
65
+ ``ToolExecutionStartData.arguments`` is typed ``Any`` by the SDK: shell
66
+ tools carry ``{"command": ...}``-shaped dicts, others arbitrary JSON.
67
+ Prefer the human-relevant fields, fall back to compact JSON.
68
+ """
69
+ if arguments is None:
70
+ return None
71
+ if isinstance(arguments, dict):
72
+ for key in ("command", "cmd", "input", "query", "path"):
73
+ value = arguments.get(key)
74
+ if isinstance(value, str) and value.strip():
75
+ return value[:TOOL_ARGS_CLIP]
76
+ try:
77
+ return json.dumps(arguments, separators=(",", ":"))[:TOOL_ARGS_CLIP]
78
+ except (TypeError, ValueError):
79
+ return str(arguments)[:TOOL_ARGS_CLIP]
80
+ text = str(arguments)
81
+ return text[:TOOL_ARGS_CLIP] if text.strip() else None
82
+
83
+
84
+ def _tool_exit_code(data: Any) -> int | None:
85
+ """Shell exit code from a ToolExecutionComplete, when present.
86
+
87
+ Shell completions carry a ShellExit entry in ``result.contents``
88
+ (verified against github-copilot-sdk: ``exit_code: int``).
89
+ """
90
+ result = getattr(data, "result", None)
91
+ for entry in getattr(result, "contents", None) or []:
92
+ code = getattr(entry, "exit_code", None)
93
+ if isinstance(code, int):
94
+ return code
95
+ return None
96
+
97
+
98
+ def _tool_output(data: Any) -> str | None:
99
+ """A bounded tail of the tool's output, for transcript display."""
100
+ result = getattr(data, "result", None)
101
+ content = getattr(result, "content", None)
102
+ if not content and result is not None:
103
+ content = getattr(result, "detailed_content", None)
104
+ if not isinstance(content, str) or not content.strip():
105
+ return None
106
+ return content[-TOOL_OUTPUT_CLIP:]
107
+
108
+
55
109
  class CopilotBackend:
56
110
  name = "copilot"
57
111
 
@@ -75,6 +129,9 @@ class CopilotBackend:
75
129
 
76
130
  usage = Usage()
77
131
  final_text: list[str] = []
132
+ # tool_call_id -> tool name, so completion events can name the tool
133
+ # (the SDK's Complete event carries only the call id).
134
+ tool_names: dict[str, str] = {}
78
135
 
79
136
  def on_event(event: Any) -> None:
80
137
  nonlocal usage
@@ -91,18 +148,25 @@ class CopilotBackend:
91
148
  final_text.append(content)
92
149
  emit(EventTypes.AGENT_MESSAGE, content=content)
93
150
  elif type_name.startswith("ToolExecutionStart"):
151
+ tool = getattr(data, "tool_name", None) or getattr(data, "toolName", None)
152
+ call_id = getattr(data, "tool_call_id", None) or getattr(data, "toolCallId", None)
153
+ if tool and call_id:
154
+ tool_names[str(call_id)] = str(tool)
94
155
  emit(
95
156
  EventTypes.AGENT_TOOL_START,
96
- tool=getattr(data, "tool_name", None) or getattr(data, "toolName", None),
97
- tool_call_id=getattr(data, "tool_call_id", None)
98
- or getattr(data, "toolCallId", None),
157
+ tool=tool,
158
+ tool_call_id=call_id,
159
+ args=_tool_args(getattr(data, "arguments", None)),
99
160
  )
100
161
  elif type_name.startswith("ToolExecutionComplete"):
162
+ call_id = getattr(data, "tool_call_id", None) or getattr(data, "toolCallId", None)
101
163
  emit(
102
164
  EventTypes.AGENT_TOOL_END,
103
- tool_call_id=getattr(data, "tool_call_id", None)
104
- or getattr(data, "toolCallId", None),
165
+ tool_call_id=call_id,
166
+ tool=tool_names.get(str(call_id)),
105
167
  success=getattr(data, "success", None),
168
+ exit_code=_tool_exit_code(data),
169
+ output=_tool_output(data),
106
170
  )
107
171
  elif type_name == "AssistantUsageData":
108
172
  sample = Usage(
@@ -142,6 +206,21 @@ class CopilotBackend:
142
206
  kwargs["model"] = job.model
143
207
  if job.system_message:
144
208
  kwargs["system_message"] = {"mode": "append", "content": job.system_message}
209
+ if job.cwd:
210
+ # Belt-and-braces alongside the worker-level chdir; the kwarg
211
+ # name is unverified against the SDK (e2e-validated), so an
212
+ # unsupported signature must not fail the session.
213
+ kwargs["working_directory"] = job.cwd
214
+ try:
215
+ return await self._open(client, job, kwargs)
216
+ except TypeError:
217
+ if "working_directory" not in kwargs:
218
+ raise
219
+ del kwargs["working_directory"]
220
+ return await self._open(client, job, kwargs)
221
+
222
+ @staticmethod
223
+ async def _open(client: Any, job: JobRequest, kwargs: dict[str, Any]) -> Any:
145
224
  if job.resume_session_id:
146
225
  return await client.resume_session(job.resume_session_id, **kwargs)
147
226
  return await client.create_session(**kwargs)
@@ -7,7 +7,11 @@ multi-job engine tests can script an entire run.
7
7
 
8
8
  Response object shape (all fields optional):
9
9
  ``{"text": str, "json": dict|list, "session_id": str, "sleep_s": float,
10
- "events": [{"type": str, "data": {...}}], "fail": str}``
10
+ "events": [{"type": str, "data": {...}}], "fail": str,
11
+ "files": {"relative/path": "content"}}``
12
+
13
+ ``files`` are written relative to the worker process cwd — modelling an
14
+ executor that produces artifacts in the run workspace.
11
15
  """
12
16
 
13
17
  from __future__ import annotations
@@ -73,6 +77,10 @@ class EchoBackend:
73
77
  raise RuntimeError(str(response["fail"]))
74
78
  if sleep_s := float(response.get("sleep_s", 0)):
75
79
  time.sleep(sleep_s)
80
+ for relative, content in response.get("files", {}).items():
81
+ target = Path.cwd() / relative
82
+ target.parent.mkdir(parents=True, exist_ok=True)
83
+ target.write_text(str(content))
76
84
  for scripted_event in response.get("events", []):
77
85
  emit(scripted_event["type"], **scripted_event.get("data", {}))
78
86
  text = str(response.get("text", ""))
@@ -92,6 +92,10 @@ class JobRequest(ProtocolModel):
92
92
 
93
93
  # kind == "shell.check"
94
94
  argv: list[str] | None = None
95
+
96
+ # agent.session + shell.check: in-sandbox working directory. The worker
97
+ # process chdirs here (via --cwd) so agent sessions and shell commands
98
+ # run in the run's canonical workspace.
95
99
  cwd: str | None = None
96
100
 
97
101
  # kind == "github.op"
File without changes