rlmness-cli 0.2.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.
Files changed (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,105 @@
1
+ """Code run in every sandbox before the model's first cell: the same functions the
2
+ local kernel has, reached through the `_rlmagent` bridge instead of a socket."""
3
+
4
+ from rlmagent.agents.child_handles import compatibility_helpers
5
+
6
+ CORE = r'''
7
+ async def guidance(id=None):
8
+ return await _rlmagent("guidance", {"id": id})
9
+
10
+ async def improve(instructions=None, shared=False):
11
+ """Ask for the playbook to be updated from this conversation when the turn ends."""
12
+ return await _rlmagent("improve", {"instructions": instructions, "shared": bool(shared)})
13
+
14
+
15
+ async def playbook(id=None):
16
+ """One playbook entry in full, or a list of all of them."""
17
+ return await _rlmagent("playbook", {"id": id})
18
+
19
+
20
+ async def skill(id):
21
+ """Load a playbook skill and return its function."""
22
+ entry = await playbook(id)
23
+ if entry["kind"] != "skill":
24
+ raise ValueError(f"{id} is a {entry['kind']}, not a skill")
25
+ namespace = {}
26
+ exec(compile(entry["content"], f"<skill {id}>", "exec"), namespace)
27
+ return namespace[entry["callable"]]
28
+ '''
29
+
30
+ DELEGATE = r'''
31
+ async def rlm(task, context=None, role=None, model=None):
32
+ """Hand `task` to a sub-agent; `context` arrives in its sandbox as CONTEXT. Returns its FINAL value."""
33
+ return await _rlmagent("rlm", {"task": str(task), "context": context, "role": role, "model": model})
34
+
35
+
36
+ async def gather_rlm(jobs):
37
+ """Run several sub-agents at once. `jobs` is a list of (task, context) pairs or tasks. Results keep order."""
38
+ pairs = [list(j) if isinstance(j, (tuple, list)) else [j, None] for j in jobs]
39
+ return await _rlmagent("gather", {"jobs": [[str(t), c] for t, c in pairs]})
40
+
41
+
42
+ class _SubAgent:
43
+ """A sub-agent started with spawn(). Losing this object does not stop it."""
44
+
45
+ def __init__(self, id):
46
+ self.id = id
47
+
48
+ def __repr__(self):
49
+ return f"<sub-agent {self.id}>"
50
+
51
+ async def status(self):
52
+ return await _rlmagent("status", {"id": self.id})
53
+
54
+ async def result(self, timeout=None):
55
+ return await _rlmagent("result", {"id": self.id, "timeout": timeout})
56
+
57
+ async def cancel(self):
58
+ return await _rlmagent("cancel", {"id": self.id})
59
+
60
+ async def send(self, text):
61
+ return await _rlmagent("send", {"id": self.id, "text": str(text)})
62
+
63
+ async def messages(self):
64
+ return await _rlmagent("messages", {"id": self.id})
65
+
66
+
67
+ async def spawn(task, data=None, keep=False, role=None, model=None):
68
+ """Start a sub-agent and return at once. keep=True: it stays idle after FINAL, ready for send()."""
69
+ return _SubAgent(await _rlmagent("spawn", {"task": str(task), "context": data,
70
+ "keep": bool(keep), "role": role, "model": model}))
71
+
72
+
73
+ async def children():
74
+ """Every sub-agent you started, in order."""
75
+ return [_SubAgent(i) for i in await _rlmagent("children", {})]
76
+ ''' + compatibility_helpers("_rlmagent")
77
+
78
+ CHILD = r'''
79
+ async def tell_parent(text):
80
+ """Leave a note the agent that started you can read with messages()."""
81
+ await _rlmagent("tell_parent", {"text": str(text)})
82
+
83
+
84
+ CONTEXT = await _rlmagent("context", {})
85
+ '''
86
+
87
+
88
+ NETWORK = r'''
89
+ import sys as _sys
90
+ for _m in ("js", "pyodide_js", "pyodide.http", "pyodide.ffi.wrappers"):
91
+ _sys.modules.pop(_m, None)
92
+ del _sys, _m
93
+ import pyodide_js
94
+ try:
95
+ await pyodide_js.loadPackage("micropip")
96
+ import micropip
97
+ await micropip.install(["httpx"])
98
+ except Exception as _e:
99
+ print(f"[network set-up failed: {_e}]")
100
+ '''
101
+
102
+
103
+ def wasm_api(can_delegate: bool, is_child: bool, network: bool = False) -> str:
104
+ return ((NETWORK if network else "") + CORE + (DELEGATE if can_delegate else "")
105
+ + (CHILD if is_child else ""))
@@ -0,0 +1,357 @@
1
+ """Host functions the sandboxed kernel uses to reach the project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import difflib
7
+ import fnmatch
8
+ import os
9
+ import re
10
+ import shutil
11
+ import time
12
+ from pathlib import Path
13
+ from rlmness.environment import execution_environment
14
+
15
+ from rlmagent.agents.approval import Approver, Request, Refused, check
16
+ from rlmagent.agents.patch import PatchError, parse, patched
17
+ from rlmagent.model.external.cli import own_group
18
+
19
+ MAX_READ = 2_000_000
20
+ MAX_OUTPUT = 50 * 1024
21
+ MAX_LIST = 2_000
22
+ MAX_MATCHES = 500
23
+ MAX_MATCH_TEXT = 300
24
+ MAX_INSTRUCTION = 64 * 1024
25
+ MAX_INSTRUCTIONS = 200
26
+ SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", ".rlm-agent"}
27
+
28
+
29
+ def _load(path: Path) -> str:
30
+ """A file's exact text: line endings are not translated."""
31
+ with open(path, encoding="utf-8", newline="") as f:
32
+ return f.read()
33
+
34
+
35
+ def _save(path: Path, text: str) -> None:
36
+ with open(path, "w", encoding="utf-8", newline="") as f:
37
+ f.write(text)
38
+
39
+
40
+ class Workspace:
41
+ def __init__(self, root: str | Path, approver: Approver) -> None:
42
+ self.root = Path(root).resolve()
43
+ self.approver = approver
44
+ self.on_output = None # called with command output as it arrives
45
+ self.policy = None # rlmagent.policy.Policy, when the run sets one
46
+
47
+ def _path(self, raw: str) -> Path:
48
+ path = (self.root / raw).resolve() # symlinks followed, so a link out is caught
49
+ if path != self.root and self.root not in path.parents:
50
+ raise ValueError(f"{raw} is outside the workspace")
51
+ return path
52
+
53
+ def _rel(self, path: Path) -> str:
54
+ return path.relative_to(self.root).as_posix()
55
+
56
+ async def read_file(self, path: str, start: int | None = None, end: int | None = None) -> str:
57
+ """The file's text, or only lines start..end (1-based, both included)."""
58
+ target = self._path(path)
59
+ ranged = start is not None or end is not None
60
+ if not ranged and target.stat().st_size > MAX_READ:
61
+ raise ValueError(
62
+ f"{path} is larger than {MAX_READ} bytes; read it in parts with start and end"
63
+ )
64
+ try:
65
+ if not ranged:
66
+ return target.read_text(encoding="utf-8")
67
+ first = max(1, int(start or 1))
68
+ last = None if end is None else int(end)
69
+ picked = []
70
+ with open(target, encoding="utf-8") as f:
71
+ for number, line in enumerate(f, 1):
72
+ if last is not None and number > last:
73
+ break
74
+ if number >= first:
75
+ picked.append(line)
76
+ return "".join(picked)
77
+ except UnicodeDecodeError:
78
+ raise ValueError(f"{path} is a binary file, not UTF-8 text") from None
79
+
80
+ async def search_files(self, pattern: str, path: str = ".", glob: str | None = None,
81
+ ignore_case: bool = False, limit: int = MAX_MATCHES) -> list[dict]:
82
+ """Lines matching a regular expression: [{"path", "line", "text"}], capped at limit."""
83
+ flags = re.IGNORECASE if ignore_case else 0
84
+ regex = re.compile(pattern, flags)
85
+ base = self._path(path)
86
+ limit = max(1, min(int(limit), MAX_MATCHES))
87
+ files = [base] if base.is_file() else self._walk(base, glob)
88
+ found: list[dict] = []
89
+ for file in files:
90
+ try:
91
+ if file.stat().st_size > MAX_READ:
92
+ continue
93
+ with open(file, encoding="utf-8", newline="") as f:
94
+ for number, line in enumerate(f, 1):
95
+ if regex.search(line):
96
+ text = line.rstrip("\r\n")
97
+ found.append({
98
+ "path": self._rel(file),
99
+ "line": number,
100
+ "text": text[:MAX_MATCH_TEXT],
101
+ })
102
+ if len(found) >= limit:
103
+ found.append({"note": f"stopped at {limit} matches; narrow the search"})
104
+ return found
105
+ except (UnicodeDecodeError, OSError):
106
+ continue
107
+ return found
108
+
109
+ def _walk(self, base: Path, glob: str | None):
110
+ for folder, dirs, names in os.walk(base, followlinks=False):
111
+ dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
112
+ for name in sorted(names):
113
+ file = Path(folder) / name
114
+ if glob and not (fnmatch.fnmatch(name, glob)
115
+ or fnmatch.fnmatch(self._rel(file), glob)):
116
+ continue
117
+ yield file
118
+
119
+ async def list_files(self, pattern: str = "**/*") -> list[str]:
120
+ found = []
121
+ for path in sorted(self.root.glob(pattern)):
122
+ rel = path.relative_to(self.root)
123
+ if path.is_file() and not SKIP_DIRS.intersection(rel.parts):
124
+ found.append(rel.as_posix())
125
+ if len(found) >= MAX_LIST:
126
+ break
127
+ return found
128
+
129
+ async def write_file(self, path: str, content: str) -> str:
130
+ target = self._path(path)
131
+ before = _load(target) if target.exists() else None
132
+ preview = self._diff(path, before or "", content)
133
+ request = Request("write", self._rel(target), (content,), preview=preview)
134
+ approved = await self.approver.approve(request)
135
+ check(approved, Request("write", self._rel(target), (content,)))
136
+ current = _load(target) if target.exists() else None
137
+ if current != before:
138
+ raise Refused(f"{path} changed while approval was pending; read it again")
139
+ target.parent.mkdir(parents=True, exist_ok=True)
140
+ _save(target, content)
141
+ return f"wrote {self._rel(target)} ({len(content)} characters)"
142
+
143
+ async def edit_file(self, path: str, old: str, new: str) -> str:
144
+ target = self._path(path)
145
+ raw = _load(target)
146
+ crlf = "\r\n" in raw
147
+ text = raw.replace("\r\n", "\n") if crlf else raw
148
+ if crlf:
149
+ old, new = old.replace("\r\n", "\n"), new.replace("\r\n", "\n")
150
+ count = text.count(old)
151
+ if count != 1:
152
+ raise ValueError(f"old text found {count} times in {path}; it must be exactly once")
153
+ request = Request(
154
+ "edit",
155
+ self._rel(target),
156
+ (old, new),
157
+ preview=self._diff(path, text, text.replace(old, new, 1)),
158
+ )
159
+ approved = await self.approver.approve(request)
160
+ check(approved, Request("edit", self._rel(target), (old, new)))
161
+ if _load(target) != raw:
162
+ raise Refused(f"{path} changed while approval was pending; read it again")
163
+ changed = text.replace(old, new, 1)
164
+ _save(target, changed.replace("\n", "\r\n") if crlf else changed)
165
+ return f"edited {self._rel(target)}"
166
+
167
+ async def apply_patch(self, diff: str) -> str:
168
+ """Apply a unified diff to any number of files, all of them or none."""
169
+ before: dict[Path, str | None] = {}
170
+ after: dict[Path, str | None] = {}
171
+ for change in parse(diff):
172
+ target = self._path(change.path)
173
+ exists = target.exists()
174
+ if change.old_path is None and exists:
175
+ raise PatchError(f"{change.path} already exists")
176
+ if change.old_path is not None and not exists:
177
+ raise PatchError(f"{change.path} does not exist")
178
+ text = _load(target) if exists else ""
179
+ before[target] = text if exists else None
180
+ after[target] = None if change.new_path is None else patched(text, change)
181
+ names = ", ".join(self._rel(t) for t in after)
182
+ approved = await self.approver.approve(Request("patch", names, (diff,), preview=diff))
183
+ check(approved, Request("patch", names, (diff,)))
184
+ for target, text in before.items():
185
+ current = _load(target) if target.exists() else None
186
+ if current != text:
187
+ raise Refused(f"{self._rel(target)} changed while approval was pending; read it again")
188
+ done: list[Path] = []
189
+ try:
190
+ for target, text in after.items():
191
+ done.append(target)
192
+ if text is None:
193
+ target.unlink()
194
+ else:
195
+ target.parent.mkdir(parents=True, exist_ok=True)
196
+ _save(target, text)
197
+ except OSError:
198
+ for target in done:
199
+ if before[target] is None:
200
+ target.unlink(missing_ok=True)
201
+ else:
202
+ _save(target, before[target])
203
+ raise
204
+ return f"patched {names}"
205
+
206
+ async def ask_user(self, question: str, choices: list[str] | None = None,
207
+ multiple: bool = False) -> str | list[str]:
208
+ """Put a question to the user and wait; text starting with Error: if nobody answers."""
209
+ if self.approver.question is None:
210
+ return "Error: nobody can answer here. Decide yourself and say what you assumed."
211
+ async with self.approver.one_question:
212
+ options = list(choices) if choices else None
213
+ answer = await (self.approver.question(str(question), options, multiple=True)
214
+ if multiple and options else
215
+ self.approver.question(str(question), options))
216
+ return answer or "Error: the user did not answer. Decide yourself and say what you assumed."
217
+
218
+ async def run(self, cmd: str, timeout: float = 120.0) -> dict:
219
+ isolated = self.policy is not None and self.policy.shell_access == "workspace"
220
+ if self.policy is not None:
221
+ from rlmagent.policy import git_refusal
222
+
223
+ if reason := git_refusal(cmd, self.policy):
224
+ raise ValueError(reason)
225
+ approved = await self.approver.approve(Request("run", cmd))
226
+ check(approved, Request("run", cmd))
227
+ bash = shutil.which("bash")
228
+ if bash is None and os.name == "nt":
229
+ candidate = (
230
+ Path(os.environ.get("ProgramFiles", "C:/Program Files")) / "Git/bin/bash.exe"
231
+ )
232
+ if candidate.is_file():
233
+ bash = str(candidate)
234
+ if bash is None and not isolated:
235
+ raise OSError("Bash is required for workspace commands")
236
+ timeout = max(0.1, min(float(timeout), 1800.0))
237
+ argv = [bash, "--noprofile", "--norc", "-c", cmd]
238
+ if isolated:
239
+ from rlmagent.shell_sandbox import plan
240
+ argv = await asyncio.to_thread(plan, str(self.root), cmd,
241
+ network=self.policy.shell_network)
242
+ return await self._execute_argv(argv, timeout, isolated=isolated)
243
+
244
+ async def _execute_argv(self, argv, timeout, *, isolated=False):
245
+ proc = await asyncio.create_subprocess_exec(
246
+ *argv,
247
+ cwd=str(self.root),
248
+ stdin=asyncio.subprocess.PIPE if isolated else asyncio.subprocess.DEVNULL,
249
+ stdout=asyncio.subprocess.PIPE,
250
+ stderr=asyncio.subprocess.STDOUT,
251
+ env=execution_environment({"PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}),
252
+ **own_group(),
253
+ )
254
+ from rlmagent.agents.output import OutputTail
255
+
256
+ tail = OutputTail()
257
+
258
+ async def drain():
259
+ shown, unsent = 0.0, bytearray()
260
+ while chunk := await proc.stdout.read(8192):
261
+ tail.feed(chunk)
262
+ if self.on_output is not None:
263
+ unsent.extend(chunk)
264
+ if time.monotonic() - shown >= 0.25:
265
+ self.on_output(bytes(unsent[-4096:]).decode("utf-8", errors="replace"))
266
+ shown = time.monotonic()
267
+ unsent.clear()
268
+ if self.on_output is not None and unsent:
269
+ self.on_output(bytes(unsent[-4096:]).decode("utf-8", errors="replace"))
270
+ await proc.wait()
271
+
272
+ reader = asyncio.create_task(drain())
273
+ try:
274
+ await asyncio.wait_for(asyncio.shield(reader), timeout)
275
+ except asyncio.CancelledError:
276
+ from rlmagent.model.external.cli import kill_tree
277
+
278
+ kill_tree(proc)
279
+ await proc.wait()
280
+ reader.cancel()
281
+ await asyncio.gather(reader, return_exceptions=True)
282
+ raise
283
+ except asyncio.TimeoutError:
284
+ from rlmagent.model.external.cli import kill_tree
285
+
286
+ kill_tree(proc)
287
+ await proc.wait()
288
+ reader.cancel()
289
+ await asyncio.gather(reader, return_exceptions=True)
290
+ return {
291
+ "exit": None,
292
+ "process_exit": proc.returncode,
293
+ "signal": -proc.returncode if proc.returncode is not None and proc.returncode < 0 else None,
294
+ "output": tail.text(),
295
+ "truncated": tail.truncated,
296
+ "output_bytes": tail.total,
297
+ "timed_out": True,
298
+ "message": f"timed out after {timeout:.0f}s",
299
+ }
300
+ return {
301
+ "exit": proc.returncode,
302
+ "process_exit": proc.returncode,
303
+ "signal": -proc.returncode if proc.returncode < 0 else None,
304
+ "timed_out": False,
305
+ "output": tail.text(),
306
+ "truncated": tail.truncated,
307
+ "output_bytes": tail.total,
308
+ }
309
+
310
+ @staticmethod
311
+ def _diff(path: str, before: str, after: str) -> str:
312
+ return "\n".join(
313
+ difflib.unified_diff(
314
+ before.splitlines(),
315
+ after.splitlines(),
316
+ fromfile=f"a/{path}",
317
+ tofile=f"b/{path}",
318
+ lineterm="",
319
+ )
320
+ )
321
+
322
+ async def instruction_docs(self, path: str | None = None):
323
+ """Discover project instruction files, or read a selected file."""
324
+ names = {"AGENTS.md", "AGENTS.override.md", "CLAUDE.md", "SKILL.md"}
325
+ if path is not None:
326
+ target = self._path(path)
327
+ if target.name not in names:
328
+ raise ValueError("choose an instruction file from instruction_docs()")
329
+ if target.stat().st_size > MAX_INSTRUCTION:
330
+ raise ValueError(f"{path} is larger than {MAX_INSTRUCTION} bytes")
331
+ return await self.read_file(path)
332
+ import os
333
+
334
+ found = []
335
+ for folder, dirs, files in os.walk(self.root, followlinks=False):
336
+ dirs[:] = sorted(
337
+ d for d in dirs if d not in SKIP_DIRS | {"dist", "build", ".hg", ".svn"}
338
+ )
339
+ for name in sorted(names.intersection(files)):
340
+ target = self._path(str(Path(folder) / name))
341
+ found.append(self._rel(target))
342
+ if len(found) >= MAX_INSTRUCTIONS:
343
+ return found
344
+ return found
345
+
346
+ def functions(self) -> dict:
347
+ return {
348
+ "read_file": self.read_file,
349
+ "list_files": self.list_files,
350
+ "search_files": self.search_files,
351
+ "write_file": self.write_file,
352
+ "edit_file": self.edit_file,
353
+ "apply_patch": self.apply_patch,
354
+ "ask_user": self.ask_user,
355
+ "run": self.run,
356
+ "instruction_docs": self.instruction_docs,
357
+ }