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.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|