sbxloop-worker 0.2.0__tar.gz

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