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,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)
|
rlmagent/model/vertex.py
ADDED
|
@@ -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
|