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,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"]
|
rlmagent/prompt_refs.py
ADDED
|
@@ -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 ""
|