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,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
|
+
}
|