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,67 @@
1
+ """Playbook entries and their JSON file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ from dataclasses import asdict, dataclass, field
9
+ from pathlib import Path
10
+
11
+ KINDS = ("rule", "memory", "skill", "role")
12
+
13
+
14
+ @dataclass
15
+ class Entry:
16
+ id: str
17
+ kind: str
18
+ title: str
19
+ content: str
20
+ scope: str
21
+ callable: str | None = None
22
+ version: int = 1
23
+ updated: float = field(default_factory=time.time)
24
+ #: Dropped this many finished tasks after `made_at_task`. None keeps it for good.
25
+ expires_after_tasks: int | None = None
26
+ made_at_task: int = 0
27
+
28
+
29
+ @dataclass
30
+ class Playbook:
31
+ entries: dict[str, Entry] = field(default_factory=dict)
32
+ history: list[dict] = field(default_factory=list)
33
+ tasks: int = 0
34
+
35
+
36
+ def load(path: Path | None) -> Playbook:
37
+ if path is None:
38
+ return Playbook()
39
+ try:
40
+ doc = json.loads(Path(path).read_text(encoding="utf-8"))
41
+ entries = {e["id"]: Entry(**e) for e in doc.get("entries", [])}
42
+ return Playbook(entries=entries, history=list(doc.get("history", [])),
43
+ tasks=int(doc.get("tasks", 0)))
44
+ except (OSError, ValueError, TypeError, KeyError):
45
+ return Playbook()
46
+
47
+
48
+ def save(path: Path | None, pb: Playbook) -> None:
49
+ if path is None:
50
+ return
51
+ path = Path(path)
52
+ path.parent.mkdir(parents=True, exist_ok=True)
53
+ doc = {"entries": [asdict(e) for e in pb.entries.values()], "history": pb.history,
54
+ "tasks": pb.tasks}
55
+ tmp = path.with_suffix(path.suffix + ".tmp")
56
+ tmp.write_text(json.dumps(doc, indent=1), encoding="utf-8")
57
+ os.replace(tmp, path)
58
+
59
+
60
+ def merged(local: Playbook, shared: Playbook) -> list[Entry]:
61
+ """Shared entries plus local ones; a local id that clashes is shown as `local:<id>`."""
62
+ out = list(shared.entries.values())
63
+ for entry in local.entries.values():
64
+ if entry.id in shared.entries:
65
+ entry = Entry(**{**asdict(entry), "id": f"local:{entry.id}"})
66
+ out.append(entry)
67
+ return out
rlmagent/plugins.py ADDED
@@ -0,0 +1,327 @@
1
+ """Local extensions: file-based discovery, dynamic import, and hot reload."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from importlib import invalidate_caches
9
+ from importlib.util import module_from_spec, spec_from_file_location
10
+ from pathlib import Path
11
+ from types import ModuleType
12
+
13
+ RLM_AGENT_HOME_ENV = "RLM_AGENT_HOME"
14
+ EXTENSIONS_DIR_ENV = "RLM_AGENT_EXTENSIONS_DIR"
15
+
16
+ #: Attribute an extension module must expose: a zero-arg callable.
17
+ ENTRY_ATTRIBUTE = "register"
18
+
19
+ #: Prefix for synthetic module names, so extensions never collide with real packages.
20
+ _MODULE_PREFIX = "rlm_agent_ext_"
21
+
22
+ #: Module names this process has imported as extensions (used by unload).
23
+ _imported: set[str] = set()
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Results
28
+ # ---------------------------------------------------------------------------
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class LoadedExtension:
33
+ """A successfully imported and registered extension."""
34
+
35
+ name: str
36
+ path: Path
37
+ module: ModuleType
38
+ value: object | None = None
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class ExtensionFailure:
43
+ """An extension that could not be imported or registered."""
44
+
45
+ name: str
46
+ path: Path
47
+ error: str
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class LoadResult:
52
+ """Outcome of one discovery + load pass."""
53
+
54
+ loaded: tuple[LoadedExtension, ...] = ()
55
+ failures: tuple[ExtensionFailure, ...] = ()
56
+ searched: tuple[Path, ...] = ()
57
+
58
+ @property
59
+ def count(self) -> int:
60
+ """Number of extensions successfully loaded."""
61
+ return len(self.loaded)
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Paths
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ def rlm_agent_home() -> Path:
70
+ """Return the rlm-agent home directory (``$RLM_AGENT_HOME`` or ``~/.rlm-agent``)."""
71
+ from rlmagent.discovery import default_paths
72
+
73
+ return default_paths().home
74
+
75
+
76
+ def extension_dirs(
77
+ *,
78
+ project_dir: Path | str | None = None,
79
+ include_project: bool = True,
80
+ extra: tuple[Path | str, ...] = (),
81
+ ) -> tuple[Path, ...]:
82
+ """Return the extension search directories, in precedence order."""
83
+ dirs: list[Path] = []
84
+
85
+ override = os.environ.get(EXTENSIONS_DIR_ENV)
86
+ dirs.append(Path(override) if override else rlm_agent_home() / "extensions")
87
+
88
+ if include_project:
89
+ from rlmagent.trust import project_inputs_allowed
90
+
91
+ root = Path(project_dir) if project_dir is not None else Path.cwd()
92
+ # Project plugins run as code, so an untrusted folder never gets them.
93
+ if project_inputs_allowed(root):
94
+ dirs.append(root / ".rlm-agent" / "extensions")
95
+
96
+ dirs.extend(Path(path) for path in extra)
97
+ return tuple(dirs)
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Discovery
102
+ # ---------------------------------------------------------------------------
103
+
104
+
105
+ def _is_hidden(name: str) -> bool:
106
+ return name.startswith("_") or name.startswith(".")
107
+
108
+
109
+ def discover_extension_files(dirs: tuple[Path, ...]) -> tuple[tuple[str, Path], ...]:
110
+ """Find ``(name, path)`` pairs for every extension in *dirs*."""
111
+ found: dict[str, Path] = {}
112
+
113
+ for directory in dirs:
114
+ if not directory.is_dir():
115
+ continue
116
+ for entry in sorted(directory.iterdir()):
117
+ if _is_hidden(entry.name):
118
+ continue
119
+ if entry.is_file() and entry.suffix == ".py":
120
+ found[entry.stem] = entry
121
+ elif entry.is_dir():
122
+ candidate = entry / "extension.py"
123
+ if candidate.is_file():
124
+ found[entry.name] = candidate
125
+
126
+ return tuple(found.items())
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Loading
131
+ # ---------------------------------------------------------------------------
132
+
133
+
134
+ def _import_module(name: str, path: Path) -> ModuleType:
135
+ """Import *path* as a module under a synthetic, collision-free name."""
136
+ module_name = f"{_MODULE_PREFIX}{name}"
137
+
138
+ # A package-style extension (dir/extension.py) needs its directory on the
139
+ # search path so sibling relative imports resolve.
140
+ search_locations = [str(path.parent)] if path.name == "extension.py" else None
141
+
142
+ spec = spec_from_file_location(
143
+ module_name, path, submodule_search_locations=search_locations
144
+ )
145
+ if spec is None:
146
+ raise ImportError(f"cannot build a module spec for {path}")
147
+
148
+ module = module_from_spec(spec)
149
+ sys.modules[module_name] = module
150
+ try:
151
+ code = compile(path.read_text(encoding="utf-8"), str(path), "exec")
152
+ exec(code, module.__dict__) # noqa: S102 - extensions are code by design
153
+ except BaseException:
154
+ sys.modules.pop(module_name, None)
155
+ raise
156
+ _imported.add(module_name)
157
+ return module
158
+
159
+
160
+ def load_extensions(
161
+ *,
162
+ project_dir: Path | str | None = None,
163
+ include_project: bool = True,
164
+ extra: tuple[Path | str, ...] = (),
165
+ ) -> LoadResult:
166
+ """Discover, import, and register every extension found on disk."""
167
+ dirs = extension_dirs(
168
+ project_dir=project_dir, include_project=include_project, extra=extra
169
+ )
170
+ loaded: list[LoadedExtension] = []
171
+ failures: list[ExtensionFailure] = []
172
+
173
+ for name, path in discover_extension_files(dirs):
174
+ try:
175
+ module = _import_module(name, path)
176
+ except BaseException as exc: # noqa: BLE001 - extensions are untrusted
177
+ failures.append(ExtensionFailure(name=name, path=path, error=str(exc)))
178
+ continue
179
+
180
+ entry = getattr(module, ENTRY_ATTRIBUTE, None)
181
+ if entry is None:
182
+ failures.append(
183
+ ExtensionFailure(
184
+ name=name,
185
+ path=path,
186
+ error=f"no {ENTRY_ATTRIBUTE}() found in {path.name}",
187
+ )
188
+ )
189
+ continue
190
+ if not callable(entry):
191
+ failures.append(
192
+ ExtensionFailure(
193
+ name=name, path=path, error=f"{ENTRY_ATTRIBUTE} is not callable"
194
+ )
195
+ )
196
+ continue
197
+
198
+ try:
199
+ value = entry()
200
+ except BaseException as exc: # noqa: BLE001 - extensions are untrusted
201
+ failures.append(
202
+ ExtensionFailure(
203
+ name=name, path=path, error=f"{ENTRY_ATTRIBUTE}() raised: {exc}"
204
+ )
205
+ )
206
+ continue
207
+
208
+ loaded.append(
209
+ LoadedExtension(name=name, path=path, module=module, value=value)
210
+ )
211
+
212
+ return LoadResult(
213
+ loaded=tuple(loaded), failures=tuple(failures), searched=dirs
214
+ )
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Reload
219
+ # ---------------------------------------------------------------------------
220
+
221
+
222
+ def contributions(result: LoadResult) -> tuple[list, dict]:
223
+ """What the loaded extensions offer: tool definitions, and functions by name.
224
+
225
+ `register()` may return a ToolSpec, a mapping of name to function, or a list
226
+ of either. Anything else is left alone, since returning nothing is normal.
227
+ """
228
+ from rlmagent.harness.contracts.tooling import ToolSpec
229
+
230
+ tools: list = []
231
+ functions: dict = {}
232
+ for extension in result.loaded:
233
+ values = extension.value if isinstance(extension.value, (list, tuple)) else [extension.value]
234
+ for value in values:
235
+ if isinstance(value, ToolSpec):
236
+ tools.append(value)
237
+ elif isinstance(value, dict):
238
+ functions.update({
239
+ name: fn for name, fn in value.items()
240
+ if str(name).isidentifier() and callable(fn)
241
+ })
242
+ return tools, functions
243
+
244
+
245
+ def as_host_function(fn):
246
+ """The function a cell calls, always a coroutine and named for its errors."""
247
+ import inspect
248
+ from functools import wraps
249
+
250
+ if inspect.iscoroutinefunction(fn):
251
+ return fn
252
+
253
+ @wraps(fn)
254
+ async def call(*args, **kwargs):
255
+ return fn(*args, **kwargs)
256
+
257
+ return call
258
+
259
+
260
+ def as_tool(name: str, fn):
261
+ """An extension function as a tool, for the engine that offers tools to the model."""
262
+ import inspect
263
+ import json
264
+
265
+ from rlmagent.harness.contracts.tooling import ToolOutcome, ToolSpec
266
+
267
+ bound = as_host_function(fn)
268
+
269
+ async def run(_call_id, arguments, _signal=None, _on_update=None):
270
+ value = await bound(**arguments)
271
+ text = value if isinstance(value, str) else json.dumps(value, default=str)
272
+ return ToolOutcome(content=text)
273
+
274
+ parameters = inspect.signature(fn).parameters
275
+ return ToolSpec(
276
+ name=name, label=name, description=inspect.getdoc(fn) or name,
277
+ parameters={
278
+ "type": "object",
279
+ "properties": {key: {} for key in parameters},
280
+ "required": [key for key, value in parameters.items()
281
+ if value.default is inspect.Parameter.empty],
282
+ },
283
+ run=run,
284
+ )
285
+
286
+
287
+ def unload_extensions() -> int:
288
+ """Drop every previously imported extension from ``sys.modules``."""
289
+ removed = 0
290
+ for module_name in tuple(_imported):
291
+ if sys.modules.pop(module_name, None) is not None:
292
+ removed += 1
293
+ _imported.discard(module_name)
294
+ invalidate_caches()
295
+ return removed
296
+
297
+
298
+ def reload_extensions(
299
+ *,
300
+ project_dir: Path | str | None = None,
301
+ include_project: bool = True,
302
+ extra: tuple[Path | str, ...] = (),
303
+ ) -> LoadResult:
304
+ """Unload, then re-discover and re-import — picks up newly written files."""
305
+ unload_extensions()
306
+ return load_extensions(
307
+ project_dir=project_dir, include_project=include_project, extra=extra
308
+ )
309
+
310
+
311
+ __all__ = [
312
+ "RLM_AGENT_HOME_ENV",
313
+ "ENTRY_ATTRIBUTE",
314
+ "EXTENSIONS_DIR_ENV",
315
+ "ExtensionFailure",
316
+ "as_host_function",
317
+ "as_tool",
318
+ "contributions",
319
+ "LoadResult",
320
+ "LoadedExtension",
321
+ "rlm_agent_home",
322
+ "discover_extension_files",
323
+ "extension_dirs",
324
+ "load_extensions",
325
+ "reload_extensions",
326
+ "unload_extensions",
327
+ ]
rlmagent/policy.py ADDED
@@ -0,0 +1,105 @@
1
+ """Run policy: limits and guards set for a session, from the command line or a contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shlex
7
+ from dataclasses import dataclass, field
8
+ from urllib.parse import urlparse
9
+
10
+ DEFAULT_OUTPUT_BYTES = 20_000
11
+
12
+
13
+ @dataclass
14
+ class Policy:
15
+ allow_git: bool = True
16
+ max_tool_output_bytes: int = DEFAULT_OUTPUT_BYTES # zero disables this output cap
17
+ max_compactions: int | None = None
18
+ agent_max_tokens: int | None = None # completion tokens one agent may spend
19
+ search_block: list[str] = field(default_factory=list)
20
+ shell_access: str = "workspace"
21
+ shell_network: bool = False
22
+ allowed_tools: frozenset[str] | None = None
23
+
24
+ def allows_tool(self, name: str) -> bool:
25
+ return self.allowed_tools is None or name in self.allowed_tools
26
+
27
+
28
+ # Git commands that only look at or change the current branch's working state.
29
+ _GIT_LOCAL = {
30
+ "status", "diff", "add", "commit", "restore", "rm", "mv", "show", "log", "blame",
31
+ "stash", "apply", "grep", "ls-files", "rev-parse", "describe", "shortlog", "mergetool",
32
+ }
33
+ # Subcommands that touch other refs, other branches, remotes or history.
34
+ _GIT_REACHING = {
35
+ "push", "pull", "fetch", "clone", "remote", "checkout", "switch", "branch", "tag",
36
+ "merge", "rebase", "reset", "cherry-pick", "revert", "worktree", "reflog", "gc",
37
+ "filter-branch", "update-ref", "symbolic-ref", "for-each-ref", "notes", "submodule",
38
+ "bisect", "replace", "prune", "am", "format-patch", "send-email", "config",
39
+ }
40
+
41
+
42
+ def _git_calls(command: str):
43
+ """The git subcommands in a shell line, split on ; && || | and newlines."""
44
+ for piece in re.split(r"&&|\|\||[;|\n]", command):
45
+ try:
46
+ words = shlex.split(piece, posix=True)
47
+ except ValueError:
48
+ words = piece.split()
49
+ while words and "=" in words[0] and not words[0].startswith("-"):
50
+ words = words[1:] # leading VAR=value assignments
51
+ if not words or words[0].rsplit("/", 1)[-1] not in ("git", "git.exe"):
52
+ continue
53
+ rest = words[1:]
54
+ while rest and rest[0].startswith("-"):
55
+ # -C dir and -c key=value take a value; other options stand alone.
56
+ rest = rest[2:] if rest[0] in ("-C", "-c") else rest[1:]
57
+ yield (rest[0] if rest else ""), rest[1:]
58
+
59
+
60
+ def git_refusal(command: str, policy: Policy) -> str | None:
61
+ """Why a shell command may not run under this policy, or None."""
62
+ if policy.allow_git:
63
+ return None
64
+ for sub, args in _git_calls(command):
65
+ if sub in _GIT_REACHING:
66
+ return (f"git {sub} touches other branches, remotes or history, which this run does not allow; "
67
+ "run; work with status, diff, add, commit and log")
68
+ if sub == "log" and any(a in ("--all", "--branches", "--remotes", "--tags") for a in args):
69
+ return "git log over other refs is turned off for this run"
70
+ if sub not in _GIT_LOCAL and sub:
71
+ return f"git {sub} is not on this run's list of allowed git commands"
72
+ return None
73
+
74
+
75
+ def cap_output(text: str, limit: int) -> str:
76
+ """Keep the start and end of long output, saying how much was left out."""
77
+ data = text.encode("utf-8", errors="replace")
78
+ if limit <= 0 or len(data) <= limit:
79
+ return text
80
+ head = data[: limit * 2 // 3].decode("utf-8", errors="ignore")
81
+ tail = data[-(limit // 3):].decode("utf-8", errors="ignore")
82
+ cut = len(data) - len(head.encode()) - len(tail.encode())
83
+ return (f"{head}\n... [{cut} bytes of output left out; print less, or slice what you "
84
+ f"need] ...\n{tail}")
85
+
86
+
87
+ def blocked_url(url: str, policy: Policy) -> bool:
88
+ """Whether a URL is on the run's block list (a domain, *.domain, or an origin)."""
89
+ parsed = urlparse(url)
90
+ host = (parsed.hostname or "").lower()
91
+ origin = f"{parsed.scheme}://{parsed.netloc}".lower()
92
+ for rule in policy.search_block:
93
+ rule = rule.lower().rstrip("/")
94
+ if "://" in rule:
95
+ if origin == rule:
96
+ return True
97
+ elif rule.startswith("*."):
98
+ if host.endswith(rule[1:]):
99
+ return True
100
+ elif host == rule or host.endswith("." + rule):
101
+ return True
102
+ return False
103
+
104
+
105
+ __all__ = ["DEFAULT_OUTPUT_BYTES", "Policy", "blocked_url", "cap_output", "git_refusal"]
@@ -0,0 +1,57 @@
1
+ """Files and links a prompt mentions, loaded before the turn so the agent starts with them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ MAX_FILES = 10
9
+ MAX_BYTES = 200_000
10
+
11
+ _LINK = re.compile(r"https?://[^\s)>\]'\"`]+")
12
+ _CANDIDATES = (
13
+ re.compile(r"`([^`\n]+)`"), # `path`
14
+ re.compile(r"@([^\s,;]+)"), # @path
15
+ re.compile(r"\]\(([^)\s]+)\)"), # [text](path)
16
+ re.compile(r"(?<![\w/.@])((?:[\w.-]+[/\\])*[\w.-]+\.\w{1,8})(?![\w/])"), # plain a/b.py
17
+ )
18
+
19
+
20
+ def _inside(path: Path, root: Path) -> bool:
21
+ return path == root or root in path.parents
22
+
23
+
24
+ def references(text: str, root: str | Path) -> dict:
25
+ """`{"prompt", "links", "files": [{"path", "content"}]}`; missing, binary, large and
26
+ outside-the-workspace files are left out."""
27
+ root = Path(root).resolve()
28
+ links = list(dict.fromkeys(m.rstrip(".,;:!?)]}") for m in _LINK.findall(text)))
29
+ bare = [word.strip("`\"'()[]{}<>,;:!?@") for word in text.split()]
30
+ seen, files = set(), []
31
+ for raw in [found for pattern in _CANDIDATES for found in pattern.findall(text)] + bare:
32
+ raw = raw.strip().rstrip(".,:;")
33
+ if not raw or raw.startswith(("http://", "https://")) or len(files) >= MAX_FILES:
34
+ continue
35
+ path = (root / raw).resolve()
36
+ if path in seen or not _inside(path, root) or not path.is_file():
37
+ continue
38
+ seen.add(path)
39
+ if path.stat().st_size > MAX_BYTES:
40
+ continue
41
+ try:
42
+ content = path.read_text(encoding="utf-8")
43
+ except (UnicodeDecodeError, OSError):
44
+ continue
45
+ files.append({"path": path.relative_to(root).as_posix(), "content": content})
46
+ return {"prompt": text, "links": links, "files": files}
47
+
48
+
49
+ def note(refs: dict) -> str:
50
+ """The line added to the prompt so the agent knows what is waiting in its kernel."""
51
+ parts = []
52
+ if refs["files"]:
53
+ parts.append("PROMPT['files'] is a list of {'path', 'content'} dicts for "
54
+ + ", ".join(f["path"] for f in refs["files"]))
55
+ if refs["links"]:
56
+ parts.append("PROMPT['links'] holds the links")
57
+ return f"\n\n[Preloaded in the python kernel: {'; '.join(parts)}.]" if parts else ""