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,223 @@
1
+ """Persistent, independently locked Responses connections per agent affinity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import json
8
+ import time
9
+ import traceback
10
+ from contextlib import suppress
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ import httpx
15
+
16
+ from rlmagent.model._oai.helpers import ServerSentEvent
17
+ from rlmagent.model._oai.transport import HttpStreamError
18
+ from rlmagent.model.transport.client import certificate_context, patched_proxy_environment
19
+
20
+
21
+ def canonical(items):
22
+ result = []
23
+ for item in items:
24
+ kind = item.get("type")
25
+ if kind == "reasoning":
26
+ continue
27
+ if item.get("role"):
28
+ content = item.get("content", [])
29
+ if isinstance(content, str):
30
+ content = [
31
+ {
32
+ "type": "output_text" if item["role"] == "assistant" else "input_text",
33
+ "text": content,
34
+ }
35
+ ]
36
+ result.append(
37
+ {
38
+ "role": item["role"],
39
+ "content": [
40
+ {
41
+ key: value
42
+ for key, value in part.items()
43
+ if key in ("type", "text", "image_url", "detail")
44
+ }
45
+ for part in content
46
+ ],
47
+ }
48
+ )
49
+ elif kind == "function_call":
50
+ arguments = item.get("arguments", "{}")
51
+ try:
52
+ arguments = json.dumps(json.loads(arguments), sort_keys=True)
53
+ except (ValueError, TypeError):
54
+ pass
55
+ result.append(
56
+ {
57
+ "type": kind,
58
+ "call_id": item.get("call_id"),
59
+ "name": item.get("name"),
60
+ "arguments": arguments,
61
+ }
62
+ )
63
+ else:
64
+ result.append(item)
65
+ return result
66
+
67
+
68
+ @dataclass
69
+ class Lane:
70
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
71
+ socket: object = None
72
+ auth: str | None = None
73
+ created: float = 0
74
+ previous: str | None = None
75
+ prefix: list = field(default_factory=list)
76
+ settings: dict = field(default_factory=dict)
77
+
78
+
79
+ class ResponsesSockets:
80
+ def __init__(self, *, timeout=120, connect=None):
81
+ self.timeout = timeout
82
+ self.connect = connect
83
+ self.lanes = {}
84
+ self.last_error = None
85
+ self.last_error_location = []
86
+
87
+ async def _discard(self, lane):
88
+ if lane.socket is not None:
89
+ with suppress(Exception):
90
+ await lane.socket.close()
91
+ lane.socket, lane.previous, lane.prefix = None, None, []
92
+
93
+ async def close(self):
94
+ for lane in self.lanes.values():
95
+ await self._discard(lane)
96
+ self.lanes.clear()
97
+
98
+ async def stream(self, url, payload, headers, *, signal=None, affinity=None):
99
+ try:
100
+ from websockets.asyncio.client import connect
101
+ from websockets.exceptions import InvalidStatus
102
+ except ImportError:
103
+ raise httpx.ConnectError(
104
+ "Install rlmagent[subscription] for WebSocket transport"
105
+ ) from None
106
+ key = affinity or "root"
107
+ temporary = key not in self.lanes and len(self.lanes) >= 32
108
+ lane = Lane() if temporary else self.lanes.setdefault(key, Lane())
109
+ async with lane.lock:
110
+ socket_url = str(httpx.URL(url).copy_with(scheme="wss"))
111
+ auth = {
112
+ name: value
113
+ for name, value in headers.items()
114
+ if name.lower() in ("authorization", "openai-organization", "openai-project")
115
+ }
116
+ fingerprint = hashlib.sha256(
117
+ (socket_url + json.dumps(auth, sort_keys=True)).encode()
118
+ ).hexdigest()
119
+ try:
120
+ if lane.auth != fingerprint or time.monotonic() - lane.created > 2700:
121
+ await self._discard(lane)
122
+ if lane.socket is None:
123
+ with patched_proxy_environment():
124
+ lane.socket = await (self.connect or connect)(
125
+ socket_url,
126
+ additional_headers=auth,
127
+ ssl=certificate_context(),
128
+ open_timeout=20,
129
+ max_size=16 * 1024 * 1024,
130
+ )
131
+ lane.auth, lane.created = fingerprint, time.monotonic()
132
+ request = {
133
+ key: value
134
+ for key, value in payload.items()
135
+ if key not in ("stream", "background")
136
+ }
137
+ original = request.get("input", [])
138
+ settings = {key: value for key, value in request.items() if key != "input"}
139
+ same_prefix = canonical(original[: len(lane.prefix)]) == lane.prefix
140
+ if lane.previous and lane.prefix and same_prefix and settings == lane.settings:
141
+ request["previous_response_id"] = lane.previous
142
+ request["input"] = original[len(lane.prefix) :]
143
+ for recovery in range(2):
144
+ await lane.socket.send(json.dumps({"type": "response.create", **request}))
145
+ visible = False
146
+ deadline = time.monotonic() + self.timeout
147
+ while True:
148
+ receiver = asyncio.create_task(lane.socket.recv())
149
+ try:
150
+ while not receiver.done():
151
+ if signal is not None and signal.is_cancelled():
152
+ await self._discard(lane)
153
+ return
154
+ if time.monotonic() >= deadline:
155
+ raise TimeoutError("Responses socket timed out")
156
+ await asyncio.wait({receiver}, timeout=0.05)
157
+ raw = receiver.result()
158
+ finally:
159
+ if not receiver.done():
160
+ receiver.cancel()
161
+ with suppress(asyncio.CancelledError):
162
+ await receiver
163
+ event = json.loads(raw)
164
+ kind = event.get("type", "")
165
+ failure = event.get("error") or event.get("response", {}).get("error") or {}
166
+ if (
167
+ failure.get("code") == "previous_response_not_found"
168
+ and recovery == 0
169
+ and not visible
170
+ and "previous_response_id" in request
171
+ ):
172
+ request.pop("previous_response_id")
173
+ request["input"] = original
174
+ lane.previous, lane.prefix = None, []
175
+ from rlmagent.agents.trace import model_attempt
176
+
177
+ model_attempt(
178
+ attempt=recovery + 1, transport="websocket", phase="cache_recovery"
179
+ )
180
+ break
181
+ visible = visible or kind in (
182
+ "response.output_text.delta",
183
+ "response.output_item.added",
184
+ )
185
+ if kind == "response.incomplete":
186
+ event = {
187
+ "type": "error",
188
+ "error": {"message": "Response was incomplete"},
189
+ }
190
+ kind = "error"
191
+ yield ServerSentEvent(event=kind, data=json.dumps(event))
192
+ if kind == "response.completed":
193
+ response = event["response"]
194
+ lane.previous = response.get("id")
195
+ lane.prefix = canonical([*original, *response.get("output", [])])
196
+ lane.settings = settings
197
+ return
198
+ if kind in ("response.failed", "error"):
199
+ lane.previous, lane.prefix = None, []
200
+ return
201
+ except InvalidStatus as error:
202
+ await self._discard(lane)
203
+ raise HttpStreamError(
204
+ error.response.status_code, "WebSocket upgrade rejected"
205
+ ) from None
206
+ except asyncio.CancelledError:
207
+ await self._discard(lane)
208
+ raise
209
+ except HttpStreamError:
210
+ raise
211
+ except Exception as error:
212
+ self.last_error = type(error).__name__
213
+ self.last_error_location = [
214
+ f"{Path(frame.filename).name}:{frame.lineno}:{frame.name}"
215
+ for frame in traceback.extract_tb(error.__traceback__)
216
+ ]
217
+ await self._discard(lane)
218
+ raise httpx.ReadError(
219
+ f"Responses WebSocket disconnected ({self.last_error})"
220
+ ) from None
221
+ finally:
222
+ if temporary:
223
+ await self._discard(lane)
@@ -0,0 +1,93 @@
1
+ """Google cloud authentication for the compatible chat endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import time
10
+ from urllib.parse import quote
11
+
12
+ from rlmagent.model.settings import ConfigError, Credential, OpenAIProfile
13
+
14
+
15
+ class ApplicationCredentials:
16
+ def __init__(self):
17
+ self.credentials = None
18
+ self.project = (
19
+ os.environ.get("GOOGLE_CLOUD_PROJECT")
20
+ or os.environ.get("CLOUDSDK_CORE_PROJECT")
21
+ or os.environ.get("GCLOUD_PROJECT")
22
+ )
23
+ self.location = os.environ.get("GOOGLE_CLOUD_LOCATION", "us-central1")
24
+ self._lock = asyncio.Lock()
25
+ self._token = None
26
+ self._expires = 0
27
+
28
+ def _gcloud(self):
29
+ if not self.project:
30
+ raise ConfigError("Set GOOGLE_CLOUD_PROJECT for the cloud endpoint")
31
+ if self._token is None or time.monotonic() >= self._expires:
32
+ executable = shutil.which("gcloud")
33
+ if executable is None:
34
+ raise ConfigError("Configure application credentials or install gcloud")
35
+ result = subprocess.run(
36
+ [executable, "auth", "application-default", "print-access-token"],
37
+ stdin=subprocess.DEVNULL,
38
+ capture_output=True,
39
+ text=True,
40
+ encoding="utf-8",
41
+ errors="replace",
42
+ timeout=20,
43
+ )
44
+ if result.returncode != 0 or not result.stdout.strip():
45
+ raise ConfigError("Cloud login failed; run gcloud auth application-default login")
46
+ self._token = result.stdout.strip()
47
+ self._expires = time.monotonic() + 3500
48
+ return self._endpoint(self._token)
49
+
50
+ def _load(self):
51
+ try:
52
+ import google.auth
53
+ from google.auth.transport.requests import Request
54
+ except ImportError:
55
+ return self._gcloud()
56
+ if self.credentials is None:
57
+ try:
58
+ self.credentials, detected = google.auth.default(
59
+ scopes=["https://www.googleapis.com/auth/cloud-platform"]
60
+ )
61
+ except google.auth.exceptions.DefaultCredentialsError:
62
+ return self._gcloud()
63
+ self.project = self.project or detected
64
+ if not self.project:
65
+ raise ConfigError("Set GOOGLE_CLOUD_PROJECT for the cloud endpoint")
66
+ if not self.credentials.valid:
67
+ self.credentials.refresh(Request())
68
+ return self._endpoint(self.credentials.token)
69
+
70
+ def _endpoint(self, token):
71
+ project, location = quote(self.project, safe=""), quote(self.location, safe="")
72
+ host = (
73
+ "aiplatform.googleapis.com"
74
+ if self.location == "global"
75
+ else f"{location}-aiplatform.googleapis.com"
76
+ )
77
+ return Credential(
78
+ token,
79
+ f"https://{host}/v1beta1/projects/{project}/locations/{location}/endpoints/openapi",
80
+ )
81
+
82
+ async def __call__(self):
83
+ async with self._lock:
84
+ return await asyncio.to_thread(self._load)
85
+
86
+
87
+ def build_vertex():
88
+ from rlmagent.model.oai_compatible import OpenAIProvider
89
+
90
+ resolver = ApplicationCredentials()
91
+ return OpenAIProvider(
92
+ OpenAIProfile(name="vertex", credential=Credential(None, ""), resolver=resolver)
93
+ )
rlmagent/network.py ADDED
@@ -0,0 +1,123 @@
1
+ """Bounded host-side text fetching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import ipaddress
7
+ import socket
8
+ from html.parser import HTMLParser
9
+ from urllib.parse import urljoin, urlsplit, urlunsplit
10
+
11
+
12
+ class _Readable(HTMLParser):
13
+ """The text of a page, without its scripts, styles and tags."""
14
+
15
+ BLOCKS = {"p", "div", "br", "li", "tr", "section", "article", "h1", "h2", "h3", "h4", "pre"}
16
+
17
+ def __init__(self) -> None:
18
+ super().__init__(convert_charrefs=True)
19
+ self.parts: list[str] = []
20
+ self.hidden = 0
21
+
22
+ def handle_starttag(self, tag, attrs):
23
+ if tag in ("script", "style", "noscript", "svg"):
24
+ self.hidden += 1
25
+ elif tag in self.BLOCKS:
26
+ self.parts.append("\n")
27
+
28
+ def handle_endtag(self, tag):
29
+ if tag in ("script", "style", "noscript", "svg") and self.hidden:
30
+ self.hidden -= 1
31
+
32
+ def handle_data(self, data):
33
+ if not self.hidden:
34
+ self.parts.append(data)
35
+
36
+
37
+ def readable(html: str) -> str:
38
+ page = _Readable()
39
+ page.feed(html)
40
+ lines = (" ".join(line.split()) for line in "".join(page.parts).splitlines())
41
+ return "\n".join(line for line in lines if line)
42
+
43
+
44
+ MAX_REDIRECTS = 5
45
+
46
+
47
+ def _is_public(address: str) -> bool:
48
+ ip = ipaddress.ip_address(address.split("%")[0])
49
+ ip = getattr(ip, "ipv4_mapped", None) or ip
50
+ return ip.is_global and not ip.is_multicast
51
+
52
+
53
+ async def _public_address(host: str) -> str:
54
+ """An address for `host`, refusing a name where any address is not public."""
55
+ try:
56
+ found = await asyncio.get_running_loop().getaddrinfo(host, None, type=socket.SOCK_STREAM)
57
+ except OSError as error:
58
+ raise ValueError(f"cannot resolve {host}: {error}") from None
59
+ addresses = [item[4][0] for item in found]
60
+ if not addresses or not all(_is_public(a) for a in addresses):
61
+ raise ValueError(f"{host} is a private or local address; fetch_url only reaches public "
62
+ "ones unless rlm-agent runs with --allow-private-fetch")
63
+ return addresses[0]
64
+
65
+
66
+ async def _open(client, url: str, allow_private: bool):
67
+ """One request. Public hosts are reached at the address just checked, so DNS cannot
68
+ answer differently for the connection; the name still goes in Host and TLS."""
69
+ parts = urlsplit(url)
70
+ if parts.scheme not in {"http", "https"} or not parts.hostname:
71
+ raise ValueError("fetch_url requires an HTTP or HTTPS URL")
72
+ if allow_private:
73
+ return await client.send(client.build_request("GET", url), stream=True)
74
+ address = await _public_address(parts.hostname)
75
+ shown = f"[{address}]" if ":" in address else address
76
+ netloc = f"{shown}:{parts.port}" if parts.port else shown
77
+ request = client.build_request(
78
+ "GET", urlunsplit(parts._replace(netloc=netloc)),
79
+ headers={"Host": parts.netloc.rsplit("@", 1)[-1]},
80
+ extensions={"sni_hostname": parts.hostname},
81
+ )
82
+ return await client.send(request, stream=True)
83
+
84
+
85
+ async def fetch_text(url: str, max_chars: int = 50000, raw: bool = False,
86
+ allow_private: bool = False) -> dict:
87
+ import httpx
88
+
89
+ limit = max(1, min(int(max_chars), 50000))
90
+ data = bytearray()
91
+ truncated = False
92
+ try:
93
+ async with httpx.AsyncClient(timeout=20, follow_redirects=False) as client:
94
+ current = url
95
+ for _ in range(MAX_REDIRECTS + 1):
96
+ response = await _open(client, current, allow_private)
97
+ if not response.is_redirect:
98
+ break
99
+ current = urljoin(current, response.headers["location"])
100
+ await response.aclose()
101
+ else:
102
+ await response.aclose()
103
+ return {"ok": False, "error": f"more than {MAX_REDIRECTS} redirects"}
104
+ try:
105
+ response.raise_for_status()
106
+ async for chunk in response.aiter_bytes(chunk_size=8192):
107
+ remaining = limit * 4 + 1 - len(data)
108
+ data.extend(chunk[:remaining])
109
+ if len(data) >= limit * 4 + 1:
110
+ truncated = True
111
+ break
112
+ text = data.decode(response.encoding or "utf-8", errors="replace")
113
+ if not raw and "html" in response.headers.get("content-type", ""):
114
+ text = readable(text)
115
+ return {
116
+ "url": current,
117
+ "text": text[:limit],
118
+ "truncated": truncated or len(text) > limit,
119
+ }
120
+ finally:
121
+ await response.aclose()
122
+ except httpx.HTTPError as error:
123
+ return {"ok": False, "error": str(error)}
rlmagent/packages.py ADDED
@@ -0,0 +1,246 @@
1
+ """Explicitly installed capability packages and their resource directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import importlib.metadata
8
+ import json
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+ from uuid import uuid4
16
+
17
+ from rlmagent.refinement.store import _lock
18
+
19
+ RETAIN_DAYS = 30
20
+
21
+
22
+ def prune(store: Path, records: dict, *, older_than=RETAIN_DAYS, apply=False) -> list[str]:
23
+ """Collect only expired inactive installs directly owned by this store."""
24
+ active = {entry["directory"] for entry in records.values()}
25
+ cutoff = time.time() - older_than * 86400
26
+ selected = []
27
+ if not store.exists():
28
+ return selected
29
+ for candidate in store.iterdir():
30
+ if candidate.name in active or not re.fullmatch(r"[a-f0-9]{32}", candidate.name):
31
+ continue
32
+ if candidate.is_symlink() or candidate.is_junction() or not candidate.is_dir():
33
+ continue
34
+ resolved = _location(candidate.name, store)
35
+ if str(resolved) in sys.path or candidate.stat().st_mtime >= cutoff:
36
+ continue
37
+ selected.append(candidate.name)
38
+ if apply:
39
+ shutil.rmtree(resolved)
40
+ return sorted(selected)
41
+
42
+
43
+ def root(*, local: bool = False, paths=None) -> Path:
44
+ from rlmagent.discovery import _resolve_home
45
+
46
+ if local:
47
+ return (paths.project if paths else Path.cwd()) / ".rlm-agent" / "packages"
48
+ return (paths.home if paths else _resolve_home()) / "packages"
49
+
50
+
51
+ def _records(store: Path | None = None) -> dict:
52
+ store = store or root()
53
+ try:
54
+ entries = json.loads((store / "installed.json").read_text(encoding="utf-8"))
55
+ except FileNotFoundError:
56
+ return {}
57
+ if not isinstance(entries, dict):
58
+ raise ValueError("Invalid installed-package registry")
59
+ for name, value in entries.items():
60
+ if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", name) or not isinstance(value, dict):
61
+ raise ValueError("Invalid installed-package registry")
62
+ _location(value["directory"], store)
63
+ return entries
64
+
65
+
66
+ def _installer(target: Path, source: str) -> list[str]:
67
+ """The command that installs `source` into `target`.
68
+
69
+ Environments made by uv (including `uv tool install`) carry no pip, so uv
70
+ installs there for this interpreter; pip is used wherever it exists.
71
+ """
72
+ import importlib.util
73
+
74
+ if importlib.util.find_spec("pip") is not None:
75
+ return [sys.executable, "-m", "pip", "install", "--target", str(target), source]
76
+ uv = shutil.which("uv")
77
+ if uv is None:
78
+ raise ValueError("Installing a package needs pip or uv in this environment")
79
+ return [uv, "pip", "install", "--python", sys.executable, "--target", str(target), source]
80
+
81
+
82
+ def _location(directory: str, store: Path | None = None) -> Path:
83
+ store = store or root()
84
+ if not isinstance(directory, str) or not re.fullmatch(r"[a-f0-9]{32}", directory):
85
+ raise ValueError("Invalid package directory")
86
+ path = (store / directory).resolve()
87
+ if not path.is_relative_to(store.resolve()):
88
+ raise ValueError("Package directory is outside the package store")
89
+ return path
90
+
91
+
92
+ def _save(records: dict, store: Path | None = None) -> None:
93
+ target = (store or root()) / "installed.json"
94
+ temporary = target.with_suffix(f".{uuid4().hex}.tmp")
95
+ try:
96
+ temporary.write_text(json.dumps(records, indent=2), encoding="utf-8")
97
+ temporary.replace(target)
98
+ finally:
99
+ temporary.unlink(missing_ok=True)
100
+
101
+
102
+ def activate() -> None:
103
+ from rlmagent.discovery import default_paths
104
+
105
+ paths = default_paths()
106
+ stores = [root(paths=paths)]
107
+ if paths.project_enabled:
108
+ stores.insert(0, root(local=True, paths=paths))
109
+ for store in stores:
110
+ for record in _records(store).values():
111
+ directory = str(_location(record["directory"], store))
112
+ if directory not in sys.path:
113
+ sys.path.append(directory)
114
+
115
+
116
+ def resource_dirs(
117
+ kind: str, *, records: dict | None = None, paths=None, store: Path | None = None
118
+ ) -> list[Path]:
119
+ if paths is not None:
120
+ stores = [root(paths=paths)]
121
+ if paths.project_enabled:
122
+ stores.insert(0, root(local=True, paths=paths))
123
+ return [
124
+ directory for location in stores for directory in resource_dirs(kind, store=location)
125
+ ]
126
+ store = store or root()
127
+ directories = []
128
+ for record in (_records(store) if records is None else records).values():
129
+ package = _location(record["directory"], store)
130
+ manifest = package / "rlmagent-resources.json"
131
+ if not manifest.is_file():
132
+ continue
133
+ resources = json.loads(manifest.read_text(encoding="utf-8"))
134
+ if not isinstance(resources, dict) or set(resources) - {"skills", "prompts", "themes"}:
135
+ raise ValueError("Invalid package resource manifest")
136
+ paths = resources.get(kind, [])
137
+ if not isinstance(paths, list) or not all(isinstance(path, str) for path in paths):
138
+ raise ValueError("Package resources must be lists of relative paths")
139
+ for path in paths:
140
+ resolved = (package / path).resolve()
141
+ if not resolved.is_relative_to(package) or not resolved.is_dir():
142
+ raise ValueError("Package resource directory is absent or outside its installation")
143
+ directories.append(resolved)
144
+ return directories
145
+
146
+
147
+ def run(argv: list[str]) -> int:
148
+ parser = argparse.ArgumentParser(prog="rlm-agent package")
149
+ sub = parser.add_subparsers(dest="action", required=True)
150
+ listing = sub.add_parser("list")
151
+ listing.add_argument("--local", action="store_true", help="Use this project's package store")
152
+ cleanup = sub.add_parser("prune", help="Review or remove expired inactive installations")
153
+ cleanup.add_argument("--local", action="store_true")
154
+ cleanup.add_argument("--older-than", type=float, default=RETAIN_DAYS, metavar="DAYS")
155
+ cleanup.add_argument("--apply", action="store_true")
156
+ install = sub.add_parser("install")
157
+ install.add_argument("name")
158
+ install.add_argument("source", help="Trusted wheel, directory or package-index requirement")
159
+ install.add_argument("--local", action="store_true", help="Install for this project only")
160
+ for action in ("remove", "update"):
161
+ operation = sub.add_parser(action)
162
+ operation.add_argument("name")
163
+ if action == "update":
164
+ operation.add_argument(
165
+ "--source", default=None, help="Replacement local artifact or index requirement"
166
+ )
167
+ operation.add_argument(
168
+ "--local", action="store_true", help="Use this project's package store"
169
+ )
170
+ args = parser.parse_args(argv)
171
+ store = root(local=args.local)
172
+ try:
173
+ if args.action == "list":
174
+ print(json.dumps(_records(store), indent=2))
175
+ return 0
176
+ if args.action == "prune":
177
+ import math
178
+
179
+ if not math.isfinite(args.older_than) or args.older_than < 0:
180
+ raise ValueError("Cache retention must be a finite nonnegative number of days")
181
+ store.mkdir(parents=True, exist_ok=True)
182
+ with _lock(store / "registry.lock"):
183
+ selected = prune(
184
+ store, _records(store), older_than=args.older_than, apply=args.apply
185
+ )
186
+ print(json.dumps({"removed" if args.apply else "candidates": selected}, indent=2))
187
+ return 0
188
+ if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", args.name):
189
+ parser.error("invalid package name")
190
+ store.mkdir(parents=True, exist_ok=True)
191
+ with _lock(store / "registry.lock"):
192
+ records = _records(store)
193
+ if args.action == "remove":
194
+ if args.name not in records:
195
+ raise ValueError("Package is not installed")
196
+ del records[args.name]
197
+ _save(records, store)
198
+ else:
199
+ source = (
200
+ args.source
201
+ if args.action == "install"
202
+ else args.source or records[args.name]["source"]
203
+ )
204
+ if (
205
+ not source
206
+ or source.startswith("-")
207
+ or "://" in source
208
+ or any(ch in source for ch in "\r\n")
209
+ ):
210
+ raise ValueError("Use a package-index requirement or a local package path")
211
+ directory = uuid4().hex
212
+ target = _location(directory, store)
213
+ result = subprocess.run(_installer(target, source), check=False)
214
+ if result.returncode:
215
+ return result.returncode
216
+ # Validate declared resources before making the installation active.
217
+ packages = [
218
+ {"name": distribution.metadata["Name"], "version": distribution.version}
219
+ for distribution in importlib.metadata.distributions(path=[str(target)])
220
+ ]
221
+ provenance = None
222
+ artifact = Path(source)
223
+ if artifact.is_file():
224
+ with artifact.open("rb") as stream:
225
+ provenance = hashlib.file_digest(stream, "sha256").hexdigest()
226
+ records[args.name] = {
227
+ "source": source,
228
+ "directory": directory,
229
+ "distributions": packages,
230
+ "artifact_sha256": provenance,
231
+ }
232
+ for kind in ("skills", "prompts", "themes"):
233
+ resource_dirs(kind, records=records, store=store)
234
+ _save(records, store)
235
+ try:
236
+ prune(store, records, apply=True)
237
+ except OSError as error:
238
+ print(
239
+ f"Package saved; cache cleanup deferred ({type(error).__name__}).",
240
+ file=sys.stderr,
241
+ )
242
+ print("Package registry updated. Restart the agent to reload Python modules.")
243
+ return 0
244
+ except (OSError, ValueError, KeyError) as exc:
245
+ print(f"Package operation failed: {exc}", file=sys.stderr)
246
+ return 1