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,219 @@
1
+ """Saved subscriptions for workspace changes and session jobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from dataclasses import dataclass
8
+ from functools import wraps
9
+ from math import isfinite
10
+ from pathlib import Path
11
+ from time import time
12
+ from uuid import uuid4
13
+
14
+ MAX_WATCHES = 32
15
+ MAX_EVENTS = 200
16
+ POLL_SECONDS = 1.0
17
+
18
+
19
+ @dataclass
20
+ class ActivityRequest:
21
+ watch_id: str | None
22
+ previous: set[str]
23
+ timeout: float
24
+ owner: str | None
25
+ deadline: float | None = None
26
+
27
+
28
+ class Subscriptions:
29
+ def __init__(self, storage: Path, snapshot):
30
+ self.storage = storage
31
+ self.snapshot = snapshot
32
+ self.task = None
33
+ try:
34
+ self.watches = json.loads(storage.read_text(encoding="utf-8"))
35
+ except FileNotFoundError:
36
+ self.watches = {}
37
+
38
+ def _save(self, watches):
39
+ self.storage.parent.mkdir(parents=True, exist_ok=True)
40
+ staged = self.storage.with_suffix(f".{uuid4().hex}.tmp")
41
+ try:
42
+ staged.write_text(json.dumps(watches, ensure_ascii=False), encoding="utf-8")
43
+ staged.replace(self.storage)
44
+ finally:
45
+ staged.unlink(missing_ok=True)
46
+ self.watches = watches
47
+
48
+ def _put(self, watch):
49
+ self._save({**self.watches, watch["id"]: watch})
50
+
51
+ def _visible(self, owner):
52
+ return {key: watch for key, watch in self.watches.items()
53
+ if watch.get("owner") in (None, owner)}
54
+
55
+ def _get(self, identity, owner=None):
56
+ if not isinstance(identity, str) or identity not in (visible := self._visible(owner)):
57
+ raise ValueError("unknown watch ID")
58
+ return visible[identity]
59
+
60
+ @staticmethod
61
+ def _summary(watch):
62
+ return {key: watch[key] for key in ("id", "kind", "target", "active", "dropped")} | {
63
+ "unread": sum(event["status"] == "unread" for event in watch["events"]),
64
+ }
65
+
66
+ async def watch_start(self, job=None, path=None):
67
+ """Subscribe to one job or workspace path. Returns a persistent watch ID."""
68
+ if (job is None) == (path is None):
69
+ raise ValueError("give exactly one of job or path")
70
+ kind, target = ("job", job) if job is not None else ("path", path)
71
+ return self._subscribe(kind, target)
72
+
73
+ def watch_child(self, child_id, owner):
74
+ return self._subscribe("child", child_id, owner)
75
+
76
+ def _subscribe(self, kind, target, owner=None):
77
+ if not isinstance(target, str) or not target:
78
+ raise ValueError("watch target must be a nonempty string")
79
+ if len(self.watches) >= MAX_WATCHES:
80
+ raise ValueError("watch limit reached; remove an old watch with watch_cancel")
81
+ target, state = self.snapshot(kind, target)
82
+ watch = {"id": uuid4().hex, "kind": kind, "target": target, "state": state, "owner": owner,
83
+ "events": [], "active": kind != "job" or not state["done"], "dropped": 0}
84
+ self._put(watch)
85
+ self.start()
86
+ return self._summary(watch)
87
+
88
+ async def watch_list(self, *, owner=None):
89
+ """List subscriptions and their unread counts."""
90
+ self.poll()
91
+ return [self._summary(watch) for watch in self._visible(owner).values()]
92
+
93
+ async def watch_events(self, watch_id, new_only=True, *, owner=None):
94
+ """List event metadata without changing read state."""
95
+ self.poll()
96
+ return [{k: v for k, v in event.items() if k != "change"}
97
+ for event in self._get(watch_id, owner)["events"]
98
+ if not new_only or event["status"] == "unread"]
99
+
100
+ async def watch_read(self, watch_id, event_id, *, owner=None):
101
+ """Read one event by ID. Repeated reads return the same event."""
102
+ watch = self._get(watch_id, owner)
103
+ for event in watch["events"]:
104
+ if event["id"] != event_id:
105
+ continue
106
+ if event["status"] == "read":
107
+ return dict(event)
108
+ read = {**event, "status": "read"}
109
+ self._put({**watch, "events": [read if row is event else row for row in watch["events"]]})
110
+ return read
111
+ raise ValueError("unknown watch event ID")
112
+
113
+ async def watch_cancel(self, watch_id, *, owner=None):
114
+ """Remove a subscription and its retained events."""
115
+ self._get(watch_id, owner)
116
+ self._save({key: value for key, value in self.watches.items() if key != watch_id})
117
+ return {"id": watch_id, "active": False}
118
+
119
+ def poll(self):
120
+ changed = {}
121
+ for key, watch in self.watches.items():
122
+ if not watch["active"]:
123
+ continue
124
+ try:
125
+ _, state = self.snapshot(watch["kind"], watch["target"])
126
+ except (OSError, ValueError):
127
+ # A target may be briefly unavailable during a job update or file swap.
128
+ continue
129
+ prior = watch["state"]
130
+ if prior == state:
131
+ continue
132
+ if watch["kind"] == "path":
133
+ paths = sorted(name for name in prior.keys() | state.keys()
134
+ if prior.get(name) != state.get(name))
135
+ change = {"paths": paths[:200], "truncated": len(paths) > 200}
136
+ elif watch["kind"] == "job":
137
+ change = {"start": prior["bytes"], "end": state["bytes"],
138
+ "done": state["done"], "exit": state["exit"]}
139
+ else:
140
+ change = dict(state)
141
+ events = [*watch["events"], {"id": uuid4().hex, "status": "unread",
142
+ "time": time(), "change": change}]
143
+ dropped = watch["dropped"] + sum(event["status"] == "unread" for event in events[:-MAX_EVENTS])
144
+ ended = (watch["kind"] == "job" and state["done"] or
145
+ watch["kind"] == "child" and state["status"] in ("cancelled", "missing"))
146
+ changed[key] = {**watch, "state": state, "events": events[-MAX_EVENTS:], "dropped": dropped,
147
+ "active": not ended}
148
+ if changed:
149
+ self._save({**self.watches, **changed})
150
+
151
+ def prepare_wait(self, watch_id, timeout, *, owner=None):
152
+ timeout = float(timeout)
153
+ if not isfinite(timeout) or not 0 < timeout <= 300:
154
+ raise ValueError("wait timeout must be between 0 and 300 seconds")
155
+ if watch_id is not None:
156
+ self._get(watch_id, owner)
157
+ self.poll()
158
+ return ActivityRequest(watch_id, self._event_ids(watch_id, owner), timeout, owner)
159
+
160
+ def _event_ids(self, watch_id, owner):
161
+ return {event["id"] for key, watch in self._visible(owner).items()
162
+ if watch_id is None or key == watch_id for event in watch["events"]}
163
+
164
+ def wait_status(self, request, steering_pending):
165
+ loop = asyncio.get_running_loop()
166
+ if request.deadline is None:
167
+ request.deadline = loop.time() + request.timeout
168
+ watch_id = request.watch_id
169
+ if steering_pending():
170
+ return {"event": "steering"}
171
+ if watch_id is not None and watch_id not in self.watches:
172
+ return {"event": "cancelled", "watch_id": watch_id}
173
+ self.poll()
174
+ if self._event_ids(watch_id, request.owner) - request.previous:
175
+ return {"event": "watch", "watch_id": watch_id}
176
+ remaining = request.deadline - loop.time()
177
+ if remaining <= 0:
178
+ return {"event": "timeout"}
179
+ return {"retry_after": min(POLL_SECONDS, remaining)}
180
+
181
+ async def wait_for_change(self, request, steering_pending):
182
+ while True:
183
+ status = self.wait_status(request, steering_pending)
184
+ if "event" in status:
185
+ return status
186
+ await asyncio.sleep(status["retry_after"])
187
+
188
+ def start(self):
189
+ if self.task is None and any(watch["active"] for watch in self.watches.values()):
190
+ self.task = asyncio.create_task(self._monitor())
191
+
192
+ async def _monitor(self):
193
+ try:
194
+ while any(watch["active"] for watch in self.watches.values()):
195
+ try:
196
+ self.poll()
197
+ except OSError:
198
+ # Keep the prior cursor and retry persistence on the next sample.
199
+ pass
200
+ await asyncio.sleep(POLL_SECONDS)
201
+ finally:
202
+ self.task = None
203
+
204
+ async def close(self):
205
+ if self.task is not None:
206
+ task = self.task
207
+ task.cancel()
208
+ await asyncio.gather(task, return_exceptions=True)
209
+ self.task = None
210
+
211
+ def functions(self, *, owner=None):
212
+ def scoped(method):
213
+ @wraps(method)
214
+ async def call(*args, **kwargs):
215
+ return await method(*args, **kwargs, owner=owner)
216
+ return call
217
+
218
+ return {"watch_start": self.watch_start, **{method.__name__: scoped(method) for method in (
219
+ self.watch_list, self.watch_events, self.watch_read, self.watch_cancel)}}
rlmagent/tasks.py ADDED
@@ -0,0 +1,98 @@
1
+ """The task list an agent keeps for itself while it works, saved with the session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ MAX_TASKS = 100
9
+ MAX_TEXT = 300
10
+
11
+
12
+ class TaskList:
13
+ def __init__(self, path: Path | None = None) -> None:
14
+ self.path = path
15
+ self.items: list[dict] = []
16
+ self.on_change = None # called after every change, e.g. to redraw a panel
17
+ self.load()
18
+
19
+ def attach(self, path: Path | None) -> None:
20
+ """Keep the list in `path` from now on, reading what is already there."""
21
+ self.path = path
22
+ self.load()
23
+
24
+ def load(self) -> None:
25
+ if self.path is None or not self.path.exists():
26
+ return
27
+ try:
28
+ data = json.loads(self.path.read_text(encoding="utf-8"))
29
+ except (OSError, json.JSONDecodeError):
30
+ return
31
+ if isinstance(data, list):
32
+ self.items = [
33
+ {"text": str(item.get("text", ""))[:MAX_TEXT], "done": bool(item.get("done"))}
34
+ for item in data if isinstance(item, dict)
35
+ ][:MAX_TASKS]
36
+
37
+ def _save(self) -> None:
38
+ if self.path is not None:
39
+ self.path.parent.mkdir(parents=True, exist_ok=True)
40
+ self.path.write_text(json.dumps(self.items, indent=1), encoding="utf-8")
41
+ if self.on_change is not None:
42
+ self.on_change(self)
43
+
44
+ def _index(self, number) -> int:
45
+ index = int(number) - 1
46
+ if not 0 <= index < len(self.items):
47
+ raise ValueError(f"there is no task {number}; tasks are numbered 1 to {len(self.items)}")
48
+ return index
49
+
50
+ def add(self, text: str) -> int:
51
+ text = " ".join(str(text).split())[:MAX_TEXT]
52
+ if not text:
53
+ raise ValueError("a task needs some text")
54
+ if len(self.items) >= MAX_TASKS:
55
+ raise ValueError(f"the list already has {MAX_TASKS} tasks; clear finished ones")
56
+ self.items.append({"text": text, "done": False})
57
+ self._save()
58
+ return len(self.items)
59
+
60
+ def done(self, number) -> None:
61
+ self.items[self._index(number)]["done"] = True
62
+ self._save()
63
+
64
+ def undo(self, number) -> None:
65
+ self.items[self._index(number)]["done"] = False
66
+ self._save()
67
+
68
+ def remove(self, number) -> None:
69
+ del self.items[self._index(number)]
70
+ self._save()
71
+
72
+ def clear(self, finished_only: bool = True) -> None:
73
+ self.items = [item for item in self.items if finished_only and not item["done"]]
74
+ self._save()
75
+
76
+ def render(self) -> str:
77
+ if not self.items:
78
+ return "No tasks."
79
+ return "\n".join(f"{n}. [{'x' if item['done'] else ' '}] {item['text']}"
80
+ for n, item in enumerate(self.items, 1))
81
+
82
+ async def todo(self, action: str = "list", value=None):
83
+ """Keep your own task list: todo() lists it; todo("add", text);
84
+ todo("done", n); todo("undo", n); todo("remove", n); todo("clear") drops finished ones."""
85
+ if action == "list":
86
+ pass
87
+ elif action == "add":
88
+ self.add(value)
89
+ elif action in ("done", "undo", "remove"):
90
+ getattr(self, action)(value)
91
+ elif action == "clear":
92
+ self.clear()
93
+ else:
94
+ raise ValueError("action is one of list, add, done, undo, remove, clear")
95
+ return self.render()
96
+
97
+
98
+ __all__ = ["TaskList"]
rlmagent/toolhost.py ADDED
@@ -0,0 +1,310 @@
1
+ """Split execution: the agent loop and its keys stay here, the tools run in another workspace.
2
+
3
+ The tool host is a small process next to the files it works on:
4
+
5
+ python -m rlmagent.toolhost --root /work # over stdin/stdout
6
+ python -m rlmagent.toolhost --root /work --tcp 0.0.0.0:7700 # over TCP
7
+
8
+ Over TCP the first line a client sends must carry the token from RLM_AGENT_TOOLS_TOKEN.
9
+ The host only runs what it is asked; approvals, the git guard and every decision about
10
+ whether something may run are made by the harness before a call is sent.
11
+
12
+ Each request is one JSON line: {"id": 1, "fn": "read_file", "args": [...], "kwargs": {...}}.
13
+ Each reply is one JSON line: {"id": 1, "ok": true, "value": ...} or {"id": 1, "ok": false,
14
+ "error": "..."}.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import asyncio
21
+ import hmac
22
+ import json
23
+ import os
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ TOKEN_ENV = "RLM_AGENT_TOOLS_TOKEN"
28
+ MAX_LINE = 64 * 1024 * 1024
29
+
30
+ # What a tool host serves. Changes and commands are approved by the harness first.
31
+ SERVED = ("read_file", "list_files", "search_files", "write_file", "edit_file", "apply_patch",
32
+ "run", "instruction_docs", "job_start", "job_read", "job_wait", "job_stop", "jobs",
33
+ "service_start", "watch")
34
+ CHANGES = {"write_file", "edit_file", "apply_patch", "run", "job_start", "service_start"}
35
+ # Positional parameter names of the calls the harness checks before sending.
36
+ _POSITIONAL = {"run": ("cmd",), "write_file": ("path", "content"),
37
+ "edit_file": ("path", "old", "new"), "apply_patch": ("diff",),
38
+ "job_start": ("command",), "service_start": ("name", "command")}
39
+
40
+
41
+ class ToolHost:
42
+ def __init__(self, root: str, *, shell_access="host", shell_network=False) -> None:
43
+ from rlmagent.agents.approval import Approver
44
+ from rlmagent.agents.workspace import Workspace
45
+ from rlmagent.jobs import Jobs
46
+
47
+ self.root = str(Path(root).resolve())
48
+ workspace = Workspace(self.root, Approver("auto"))
49
+ from rlmagent.policy import Policy
50
+ workspace.policy = Policy(shell_access=shell_access, shell_network=shell_network)
51
+ self.jobs = Jobs(self.root, Path(self.root) / ".rlm-agent" / "jobs",
52
+ shell_access=shell_access, shell_network=shell_network)
53
+ self.functions = {**workspace.functions(), **self.jobs.functions()}
54
+
55
+ async def answer(self, request: dict) -> dict:
56
+ number = request.get("id")
57
+ name = request.get("fn")
58
+ if name not in SERVED or name not in self.functions:
59
+ return {"id": number, "ok": False, "error": f"the tool host does not serve {name!r}"}
60
+ try:
61
+ value = await self.functions[name](*request.get("args", []), **request.get("kwargs", {}))
62
+ except Exception as exc: # noqa: BLE001 - every failure goes back to the caller
63
+ return {"id": number, "ok": False, "error": f"{type(exc).__name__}: {exc}"}
64
+ return {"id": number, "ok": True, "value": value}
65
+
66
+ async def serve(self, reader, writer, token: str | None = None) -> None:
67
+ if token is not None:
68
+ first = await reader.readline()
69
+ try:
70
+ given = str(json.loads(first).get("token", ""))
71
+ except (json.JSONDecodeError, AttributeError):
72
+ given = ""
73
+ if not hmac.compare_digest(given, token):
74
+ writer.write(b'{"ok": false, "error": "bad token"}\n')
75
+ await writer.drain()
76
+ writer.close()
77
+ return
78
+ writer.write(b'{"ok": true}\n')
79
+ await writer.drain()
80
+ pending = set()
81
+ lock = asyncio.Lock()
82
+
83
+ async def handle(line: bytes) -> None:
84
+ try:
85
+ request = json.loads(line)
86
+ except json.JSONDecodeError:
87
+ return
88
+ reply = await self.answer(request)
89
+ data = (json.dumps(reply, default=str) + "\n").encode()
90
+ async with lock:
91
+ writer.write(data)
92
+ await writer.drain()
93
+
94
+ while line := await reader.readline():
95
+ task = asyncio.create_task(handle(line))
96
+ pending.add(task)
97
+ task.add_done_callback(pending.discard)
98
+ await asyncio.gather(*pending, return_exceptions=True)
99
+
100
+ async def close(self) -> None:
101
+ await self.jobs.close()
102
+
103
+
104
+ class RemoteTools:
105
+ """The harness's side: calls tools in a tool host over a stream."""
106
+
107
+ def __init__(self, reader, writer, process=None) -> None:
108
+ self.reader, self.writer, self.process = reader, writer, process
109
+ self._next = 0
110
+ self._waiting: dict[int, asyncio.Future] = {}
111
+ self._reading = asyncio.create_task(self._read())
112
+
113
+ @classmethod
114
+ async def over_stdio(cls, command: list[str]) -> RemoteTools:
115
+ process = await asyncio.create_subprocess_exec(
116
+ *command, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
117
+ stderr=asyncio.subprocess.DEVNULL, limit=MAX_LINE)
118
+ return cls(process.stdout, process.stdin, process)
119
+
120
+ @classmethod
121
+ async def over_tcp(cls, host: str, port: int, token: str) -> RemoteTools:
122
+ reader, writer = await asyncio.open_connection(host, port, limit=MAX_LINE)
123
+ writer.write((json.dumps({"token": token}) + "\n").encode())
124
+ await writer.drain()
125
+ hello = json.loads(await reader.readline() or b"{}")
126
+ if not hello.get("ok"):
127
+ writer.close()
128
+ raise ConnectionError(f"the tool host refused the connection: {hello.get('error')}")
129
+ return cls(reader, writer)
130
+
131
+ async def _read(self) -> None:
132
+ try:
133
+ while line := await self.reader.readline():
134
+ reply = json.loads(line)
135
+ waiter = self._waiting.pop(reply.get("id"), None)
136
+ if waiter is not None and not waiter.done():
137
+ waiter.set_result(reply)
138
+ finally:
139
+ for waiter in self._waiting.values():
140
+ if not waiter.done():
141
+ waiter.set_exception(ConnectionError("the tool host went away"))
142
+
143
+ async def call(self, name: str, *args, **kwargs):
144
+ self._next += 1
145
+ number = self._next
146
+ waiter = asyncio.get_running_loop().create_future()
147
+ self._waiting[number] = waiter
148
+ line = json.dumps({"id": number, "fn": name, "args": list(args), "kwargs": kwargs})
149
+ self.writer.write((line + "\n").encode())
150
+ await self.writer.drain()
151
+ reply = await waiter
152
+ if not reply.get("ok"):
153
+ raise ValueError(reply.get("error", "the tool host failed"))
154
+ return reply.get("value")
155
+
156
+ def functions(self, approver, policy=None) -> dict:
157
+ """The served tools as cell functions, approved here before they are sent."""
158
+ import difflib
159
+
160
+ from rlmagent.agents.approval import Request, check
161
+ from rlmagent.policy import git_refusal
162
+
163
+ async def approve(request: Request) -> None:
164
+ check(await approver.approve(request), Request(request.kind, request.target,
165
+ request.payload))
166
+
167
+ def make(name):
168
+ async def call(*args, **kwargs):
169
+ given = {**dict(zip(_POSITIONAL.get(name, ()), args)), **kwargs}
170
+ if name in ("run", "job_start", "service_start"):
171
+ command = str(given.get("cmd", given.get("command", "")))
172
+ argv = given.get("argv") if name == "job_start" else None
173
+ if argv is not None:
174
+ import shlex
175
+ if not isinstance(argv, list) or not argv or not all(
176
+ isinstance(item, str) for item in argv
177
+ ):
178
+ raise ValueError("argv must be a nonempty list of strings")
179
+ command = shlex.join(argv)
180
+ if policy is not None and (reason := git_refusal(command, policy)):
181
+ raise ValueError(reason)
182
+ payload = ("argv", tuple(argv)) if argv is not None else ()
183
+ await approve(Request("run", command, payload))
184
+ elif name == "write_file":
185
+ path, content = str(given.get("path")), str(given.get("content", ""))
186
+ try:
187
+ before = await self.call("read_file", path)
188
+ except ValueError:
189
+ before = ""
190
+ preview = "\n".join(difflib.unified_diff(
191
+ before.splitlines(), content.splitlines(), f"a/{path}", f"b/{path}",
192
+ lineterm=""))
193
+ await approve(Request("write", path, (content,), preview=preview))
194
+ elif name == "edit_file":
195
+ await approve(Request("edit", str(given.get("path")),
196
+ (given.get("old"), given.get("new"))))
197
+ elif name == "apply_patch":
198
+ diff = str(given.get("diff", ""))
199
+ await approve(Request("patch", "remote workspace", (diff,), preview=diff))
200
+ return await self.call(name, *args, **kwargs)
201
+
202
+ call.__name__ = name
203
+ return call
204
+
205
+ return {name: make(name) for name in SERVED}
206
+
207
+ async def close(self) -> None:
208
+ self.writer.close()
209
+ self._reading.cancel()
210
+ if self.process is not None and self.process.returncode is None:
211
+ self.process.kill()
212
+ await self.process.wait()
213
+
214
+
215
+ async def connect(spec: str) -> RemoteTools:
216
+ """`tcp://host:port` (token from RLM_AGENT_TOOLS_TOKEN), or a command run over stdio."""
217
+ import shlex
218
+
219
+ if spec.startswith("tcp://"):
220
+ host, _, port = spec[len("tcp://"):].rpartition(":")
221
+ token = os.environ.get(TOKEN_ENV)
222
+ if not token:
223
+ raise ValueError(f"set {TOKEN_ENV} to the tool host's token")
224
+ return await RemoteTools.over_tcp(host or "127.0.0.1", int(port), token)
225
+ words = shlex.split(spec, posix=os.name != "nt")
226
+ if os.name == "nt":
227
+ # Windows splitting keeps the quotes that protect paths with spaces.
228
+ words = [w[1:-1] if len(w) > 1 and w[0] == w[-1] and w[0] in "\"'" else w for w in words]
229
+ return await RemoteTools.over_stdio(words)
230
+
231
+
232
+ async def _stdio(host: ToolHost) -> None:
233
+ loop = asyncio.get_running_loop()
234
+ reader = asyncio.StreamReader(limit=MAX_LINE)
235
+ await loop.connect_read_pipe(lambda: asyncio.StreamReaderProtocol(reader), sys.stdin)
236
+ transport, protocol = await loop.connect_write_pipe(
237
+ asyncio.streams.FlowControlMixin, sys.stdout)
238
+ writer = asyncio.StreamWriter(transport, protocol, reader, loop)
239
+ await host.serve(reader, writer)
240
+
241
+
242
+ def main(argv: list[str] | None = None) -> int:
243
+ parser = argparse.ArgumentParser(prog="python -m rlmagent.toolhost",
244
+ description="Serve workspace tools to an rlm-agent harness.")
245
+ parser.add_argument("--root", default=".", help="The workspace the tools work in.")
246
+ parser.add_argument("--tcp", default=None, metavar="HOST:PORT",
247
+ help=f"Listen on TCP; clients must send the token in {TOKEN_ENV}.")
248
+ parser.add_argument("--shell-access", choices=("workspace", "host"), default="workspace")
249
+ parser.add_argument("--shell-network", action="store_true")
250
+ ns = parser.parse_args(argv)
251
+ host = ToolHost(ns.root, shell_access=ns.shell_access, shell_network=ns.shell_network)
252
+
253
+ async def run() -> None:
254
+ try:
255
+ if ns.tcp is None:
256
+ if os.name == "nt":
257
+ # Pipes on Windows do not work with the selector loop's pipe readers.
258
+ await _stdio_threads(host)
259
+ else:
260
+ await _stdio(host)
261
+ return
262
+ token = os.environ.get(TOKEN_ENV)
263
+ if not token:
264
+ raise SystemExit(f"set {TOKEN_ENV} before listening on TCP")
265
+ address, _, port = ns.tcp.rpartition(":")
266
+ server = await asyncio.start_server(
267
+ lambda r, w: host.serve(r, w, token), address or "127.0.0.1", int(port),
268
+ limit=MAX_LINE)
269
+ print(f"tool host listening on {ns.tcp} for {host.root}", file=sys.stderr, flush=True)
270
+ async with server:
271
+ await server.serve_forever()
272
+ finally:
273
+ await host.close()
274
+
275
+ asyncio.run(run())
276
+ return 0
277
+
278
+
279
+ async def _stdio_threads(host: ToolHost) -> None:
280
+ """stdin/stdout through threads, for platforms whose event loop cannot watch pipes."""
281
+ loop = asyncio.get_running_loop()
282
+ reader = asyncio.StreamReader(limit=MAX_LINE)
283
+
284
+ def pump():
285
+ stream = sys.stdin.buffer
286
+ while line := stream.readline():
287
+ loop.call_soon_threadsafe(reader.feed_data, line)
288
+ loop.call_soon_threadsafe(reader.feed_eof)
289
+
290
+ loop.run_in_executor(None, pump)
291
+
292
+ class Writer:
293
+ def write(self, data: bytes) -> None:
294
+ sys.stdout.buffer.write(data)
295
+ sys.stdout.buffer.flush()
296
+
297
+ async def drain(self) -> None:
298
+ return None
299
+
300
+ def close(self) -> None:
301
+ return None
302
+
303
+ await host.serve(reader, Writer())
304
+
305
+
306
+ if __name__ == "__main__":
307
+ raise SystemExit(main())
308
+
309
+
310
+ __all__ = ["CHANGES", "SERVED", "TOKEN_ENV", "RemoteTools", "ToolHost", "connect", "main"]
@@ -0,0 +1,56 @@
1
+ """Coding tools (files, the python kernel) + tool registry/assembly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rlmagent.tools._shared import (
6
+ CallBlock,
7
+ CancelToken,
8
+ InputShaper,
9
+ InvocationPrinter,
10
+ JValue,
11
+ OutcomePresenter,
12
+ Parallelism,
13
+ ProgressNotifier,
14
+ RunHandler,
15
+ ToolOutcome,
16
+ ToolSpec,
17
+ )
18
+ from rlmagent.tools.files import file_tools
19
+ from rlmagent.tools.kernel import make_python_tool
20
+ from rlmagent.tools.patch_tool import make_patch_tool
21
+ from rlmagent.tools.search import search_tools
22
+
23
+
24
+ def build_tool_registry(kernel=None) -> list[ToolSpec]:
25
+ """File tools, plus the python tool when the agent has a kernel."""
26
+ from rlmagent.kernel.wasm import WasmKernel
27
+
28
+ tools: list[ToolSpec] = []
29
+ # A sandboxed agent changes files only through its approved workspace functions.
30
+ sandboxed = isinstance(kernel, WasmKernel) or getattr(kernel, "kernel_kind", None) == "wasm"
31
+ tools.extend(t for t in file_tools() if not sandboxed or t.name == "Read")
32
+ if not sandboxed:
33
+ tools.append(make_patch_tool())
34
+ tools.extend(search_tools())
35
+ if kernel is not None:
36
+ tools.append(make_python_tool(kernel))
37
+ return tools
38
+
39
+
40
+ __all__ = [
41
+ "CallBlock",
42
+ "CancelToken",
43
+ "InputShaper",
44
+ "InvocationPrinter",
45
+ "JValue",
46
+ "OutcomePresenter",
47
+ "Parallelism",
48
+ "ProgressNotifier",
49
+ "RunHandler",
50
+ "ToolOutcome",
51
+ "ToolSpec",
52
+ "build_tool_registry",
53
+ "file_tools",
54
+ "make_python_tool",
55
+ "search_tools",
56
+ ]