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,88 @@
1
+ """Build a shell with a private filesystem and an explicit workspace mount."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from functools import lru_cache
10
+ from pathlib import Path
11
+
12
+ from rlmness.environment import execution_environment
13
+
14
+ _SUPERVISE = """
15
+ import json, os, signal, subprocess, sys, threading
16
+ child = subprocess.Popen(json.loads(sys.argv[1]), stdin=subprocess.DEVNULL, start_new_session=True)
17
+ def stop():
18
+ try: os.killpg(child.pid, signal.SIGKILL)
19
+ except ProcessLookupError: pass
20
+ def watch():
21
+ while os.read(0, 4096): pass
22
+ stop()
23
+ threading.Thread(target=watch, daemon=True).start()
24
+ try: code = child.wait()
25
+ finally: stop()
26
+ raise SystemExit(code if code >= 0 else 128-code)
27
+ """
28
+
29
+
30
+ class SandboxUnavailable(OSError):
31
+ pass
32
+
33
+
34
+ @lru_cache(maxsize=1)
35
+ def _runner():
36
+ if os.name == "nt":
37
+ wsl = Path(os.environ.get("SystemRoot", "C:/Windows")) / "System32/wsl.exe"
38
+ prefix = [str(wsl), "--exec"]
39
+ probe = prefix + ["/usr/bin/bwrap", "--version"]
40
+ elif sys.platform.startswith("linux"):
41
+ bwrap = shutil.which("bwrap")
42
+ if bwrap is None:
43
+ raise SandboxUnavailable("Install Bubblewrap for workspace-isolated shell execution")
44
+ prefix, probe = [], [bwrap, "--version"]
45
+ else:
46
+ raise SandboxUnavailable("Workspace shell isolation requires Linux/Bubblewrap or WSL")
47
+ try:
48
+ answer = subprocess.run(probe, capture_output=True, timeout=30,
49
+ env=execution_environment())
50
+ except (OSError, subprocess.TimeoutExpired) as error:
51
+ raise SandboxUnavailable("Workspace shell isolation is unavailable; configure Bubblewrap "
52
+ "in Linux/WSL or explicitly grant --shell-access host") from error
53
+ if answer.returncode:
54
+ raise SandboxUnavailable("Bubblewrap is unavailable in the selected Linux/WSL environment; "
55
+ "install it or explicitly grant --shell-access host")
56
+ return prefix, probe[len(prefix)]
57
+
58
+
59
+ def plan(cwd, command, *, argv=None, env=None, network=False):
60
+ """Return a supervised argv. Keep its stdin pipe open for its entire lifetime."""
61
+ prefix, bwrap = _runner()
62
+ workspace = str(Path(cwd).resolve())
63
+ if prefix:
64
+ mapped = subprocess.run(prefix + ["/usr/bin/wslpath", "-a", "-u", workspace],
65
+ capture_output=True, text=True, encoding="utf-8", timeout=15,
66
+ env=execution_environment())
67
+ if mapped.returncode or not mapped.stdout.strip().startswith("/"):
68
+ raise SandboxUnavailable("WSL could not resolve the workspace mount")
69
+ workspace = mapped.stdout.strip()
70
+ options = [bwrap, "--die-with-parent", "--new-session", "--unshare-user",
71
+ "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--cap-drop", "ALL",
72
+ "--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin",
73
+ "--ro-bind", "/lib", "/lib", "--ro-bind-try", "/lib64", "/lib64",
74
+ "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", "/tmp/home",
75
+ "--bind", workspace, "/workspace", "--chdir", "/workspace", "--clearenv"]
76
+ if not network:
77
+ options += ["--unshare-net"]
78
+ else:
79
+ for name in ("resolv.conf", "hosts", "nsswitch.conf"):
80
+ options += ["--ro-bind-try", "/etc/" + name, "/etc/" + name]
81
+ options += ["--ro-bind-try", "/etc/ssl/certs", "/etc/ssl/certs"]
82
+ variables = {"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": "/tmp/home",
83
+ "LANG": "C.UTF-8", "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1", **(env or {})}
84
+ for key, value in variables.items():
85
+ if value is not None:
86
+ options += ["--setenv", str(key), str(value)]
87
+ options += ["--", *(argv or ["/bin/bash", "--noprofile", "--norc", "-c", command])]
88
+ return [*prefix, "/usr/bin/python3", "-I", "-c", _SUPERVISE, json.dumps(options)]
rlmagent/skillset.py ADDED
@@ -0,0 +1,320 @@
1
+ """Markdown-based skill system: discovery, loading, expansion, and indexing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+ from pathlib import Path
10
+
11
+ # ── constants ──────────────────────────────────────────────────────────────
12
+
13
+ _SKILL_FILE = "SKILL.md"
14
+ _AGENTS_FILE = "AGENTS.md"
15
+ _FRONT_MATTER_FENCE = "---"
16
+ _COMMAND_PREFIX = "/skill:"
17
+ _INVOCATION_TAG = "skill-invocation"
18
+
19
+
20
+ # ── diagnostic levels ─────────────────────────────────────────────────────
21
+
22
+
23
+ class Severity(Enum):
24
+ """Non-fatal diagnostic severity."""
25
+
26
+ INFO = "info"
27
+ WARNING = "warning"
28
+
29
+
30
+ # ── data models ────────────────────────────────────────────────────────────
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class Skill:
35
+ """An immutable, loaded skill definition."""
36
+
37
+ name: str
38
+ description: str
39
+ body: str
40
+ source: Path
41
+
42
+ @property
43
+ def command(self) -> str:
44
+ """The slash command that invokes this skill."""
45
+ return f"{_COMMAND_PREFIX}{self.name}"
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class SkillInvocation:
50
+ """A parsed ``/skill:<name>`` expansion ready for prompt injection."""
51
+
52
+ name: str
53
+ arguments: str
54
+ body: str
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class SkillDiagnostic:
59
+ """A non-fatal issue encountered during skill discovery."""
60
+
61
+ severity: Severity
62
+ path: Path
63
+ message: str
64
+
65
+
66
+ class SkillLoadError(Exception):
67
+ """Fatal error during skill loading (unreadable file, bad encoding)."""
68
+
69
+
70
+ # ── front-matter parsing ──────────────────────────────────────────────────
71
+
72
+ _FRONT_MATTER_RE = re.compile(
73
+ r"\A---[ \t]*\n(.*?\n)---[ \t]*\n",
74
+ re.DOTALL,
75
+ )
76
+
77
+
78
+ def _parse_front_matter(text: str) -> tuple[dict[str, str], str]:
79
+ """Split optional YAML front matter from the markdown body."""
80
+ match = _FRONT_MATTER_RE.match(text)
81
+ if match is None:
82
+ return {}, text
83
+
84
+ raw_yaml = match.group(1)
85
+ body = text[match.end():]
86
+ meta: dict[str, str] = {}
87
+ for line in raw_yaml.splitlines():
88
+ line = line.strip()
89
+ if not line or line.startswith("#"):
90
+ continue
91
+ colon = line.find(":")
92
+ if colon < 1:
93
+ continue
94
+ key = line[:colon].strip()
95
+ value = line[colon + 1:].strip()
96
+ # Strip surrounding quotes
97
+ if len(value) >= 2 and value[0] in ('"', "'") and value[-1] == value[0]:
98
+ value = value[1:-1]
99
+ meta[key] = value
100
+
101
+ return meta, body
102
+
103
+
104
+ def _derive_description(body: str) -> str:
105
+ """Extract the first non-heading, non-empty paragraph as a description."""
106
+ for line in body.splitlines():
107
+ stripped = line.strip()
108
+ if not stripped:
109
+ continue
110
+ if stripped.startswith("#"):
111
+ continue
112
+ if stripped.startswith("---"):
113
+ continue
114
+ return stripped
115
+ return ""
116
+
117
+
118
+ # ── single-skill loading ──────────────────────────────────────────────────
119
+
120
+
121
+ def _load_skill_from_dir(skill_dir: Path) -> Skill:
122
+ """Read ``SKILL.md`` from *skill_dir* and return a ``Skill``."""
123
+ md_path = skill_dir / _SKILL_FILE
124
+ try:
125
+ raw = md_path.read_text(encoding="utf-8")
126
+ except OSError as exc:
127
+ raise SkillLoadError(f"Cannot read {md_path}: {exc}") from exc
128
+ except UnicodeDecodeError as exc:
129
+ raise SkillLoadError(
130
+ f"{md_path} is not valid UTF-8: {exc}"
131
+ ) from exc
132
+
133
+ meta, body = _parse_front_matter(raw)
134
+ name = meta.get("name", skill_dir.name)
135
+ description = meta.get("description", "") or _derive_description(body)
136
+
137
+ return Skill(
138
+ name=name,
139
+ description=description,
140
+ body=body,
141
+ source=md_path,
142
+ )
143
+
144
+
145
+ # ── multi-directory discovery ─────────────────────────────────────────────
146
+
147
+
148
+ def _discover_in_directory(
149
+ root: Path,
150
+ ) -> tuple[list[Skill], list[SkillDiagnostic]]:
151
+ """Scan a single resource directory for skills."""
152
+ skills: list[Skill] = []
153
+ diagnostics: list[SkillDiagnostic] = []
154
+ seen_names: dict[str, Path] = {}
155
+
156
+ if not root.is_dir():
157
+ return skills, diagnostics
158
+
159
+ for child in sorted(root.iterdir()):
160
+ # Skip AGENTS.md
161
+ if child.name == _AGENTS_FILE:
162
+ continue
163
+
164
+ # Bare .md files are not skills
165
+ if child.is_file() and child.suffix.lower() == ".md":
166
+ diagnostics.append(SkillDiagnostic(
167
+ severity=Severity.INFO,
168
+ path=child,
169
+ message=(
170
+ f"Bare markdown file ignored. To make it a skill, "
171
+ f"move it to {child.stem}/{_SKILL_FILE}"
172
+ ),
173
+ ))
174
+ continue
175
+
176
+ # Only directories with SKILL.md
177
+ if not child.is_dir():
178
+ continue
179
+ if not (child / _SKILL_FILE).is_file():
180
+ continue
181
+
182
+ try:
183
+ skill = _load_skill_from_dir(child)
184
+ except SkillLoadError as exc:
185
+ diagnostics.append(SkillDiagnostic(
186
+ severity=Severity.WARNING,
187
+ path=child,
188
+ message=str(exc),
189
+ ))
190
+ continue
191
+
192
+ if skill.name in seen_names:
193
+ diagnostics.append(SkillDiagnostic(
194
+ severity=Severity.WARNING,
195
+ path=child,
196
+ message=(
197
+ f"Duplicate skill name {skill.name!r} in same directory; "
198
+ f"already defined at {seen_names[skill.name]}"
199
+ ),
200
+ ))
201
+ continue
202
+
203
+ seen_names[skill.name] = child
204
+ skills.append(skill)
205
+
206
+ return skills, diagnostics
207
+
208
+
209
+ def load_skills_with_diagnostics(
210
+ resource_dirs: Sequence[Path],
211
+ ) -> tuple[list[Skill], list[SkillDiagnostic]]:
212
+ """Load skills from multiple resource directories with diagnostics."""
213
+ combined: dict[str, Skill] = {}
214
+ all_diagnostics: list[SkillDiagnostic] = []
215
+
216
+ for root in resource_dirs:
217
+ skills, diags = _discover_in_directory(root)
218
+ all_diagnostics.extend(diags)
219
+ for skill in skills:
220
+ if skill.name not in combined:
221
+ combined[skill.name] = skill
222
+
223
+ return list(combined.values()), all_diagnostics
224
+
225
+
226
+ def load_skills(resource_dirs: Sequence[Path]) -> list[Skill]:
227
+ """Load skills from resource directories, discarding diagnostics."""
228
+ skills, _ = load_skills_with_diagnostics(resource_dirs)
229
+ return skills
230
+
231
+
232
+ # ── command expansion ──────────────────────────────────────────────────────
233
+
234
+
235
+ def expand_command(
236
+ text: str,
237
+ skills: dict[str, Skill],
238
+ ) -> str | None:
239
+ """Expand a ``/skill:<name>`` command into an XML invocation block."""
240
+ if not text.startswith(_COMMAND_PREFIX):
241
+ return None
242
+
243
+ rest = text[len(_COMMAND_PREFIX):]
244
+ parts = rest.split(None, 1)
245
+ name = parts[0] if parts else rest
246
+ arguments = parts[1] if len(parts) > 1 else ""
247
+
248
+ if name not in skills:
249
+ raise KeyError(f"Unknown skill: {name!r}")
250
+
251
+ skill = skills[name]
252
+ return format_invocation(SkillInvocation(
253
+ name=name,
254
+ arguments=arguments,
255
+ body=skill.body,
256
+ ))
257
+
258
+
259
+ # ── invocation formatting / parsing ────────────────────────────────────────
260
+
261
+ _INVOCATION_RE = re.compile(
262
+ rf"<{_INVOCATION_TAG}\s+name=\"([^\"]+)\"(?:\s+arguments=\"([^\"]*)\")?>\n"
263
+ rf"(.*?)"
264
+ rf"</{_INVOCATION_TAG}>",
265
+ re.DOTALL,
266
+ )
267
+
268
+
269
+ def format_invocation(invocation: SkillInvocation) -> str:
270
+ """Render a ``SkillInvocation`` as an XML-like block."""
271
+ attrs = f'name="{invocation.name}"'
272
+ if invocation.arguments:
273
+ attrs += f' arguments="{invocation.arguments}"'
274
+ return (
275
+ f"<{_INVOCATION_TAG} {attrs}>\n"
276
+ f"{invocation.body}\n"
277
+ f"</{_INVOCATION_TAG}>"
278
+ )
279
+
280
+
281
+ def parse_invocation(text: str) -> SkillInvocation | None:
282
+ """Parse an XML-like skill invocation block back into a ``SkillInvocation``."""
283
+ match = _INVOCATION_RE.search(text)
284
+ if match is None:
285
+ return None
286
+ return SkillInvocation(
287
+ name=match.group(1),
288
+ arguments=match.group(2) or "",
289
+ body=match.group(3).strip(),
290
+ )
291
+
292
+
293
+ # ── skill index for system prompts ────────────────────────────────────────
294
+
295
+
296
+ def build_skill_index(skills: Sequence[Skill]) -> str:
297
+ """Build a concise skill listing suitable for inclusion in a system prompt."""
298
+ if not skills:
299
+ return ""
300
+ lines = [
301
+ f" {skill.command} — {skill.description}"
302
+ for skill in sorted(skills, key=lambda s: s.name)
303
+ ]
304
+ header = "Available skills (invoke with /skill:<name>):"
305
+ return header + "\n" + "\n".join(lines)
306
+
307
+
308
+ __all__ = [
309
+ "Severity",
310
+ "Skill",
311
+ "SkillDiagnostic",
312
+ "SkillInvocation",
313
+ "SkillLoadError",
314
+ "build_skill_index",
315
+ "expand_command",
316
+ "format_invocation",
317
+ "load_skills",
318
+ "load_skills_with_diagnostics",
319
+ "parse_invocation",
320
+ ]
rlmagent/steering.py ADDED
@@ -0,0 +1,148 @@
1
+ """Accepted instructions and model-boundary delivery receipts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import json
7
+ import threading
8
+ import time
9
+ from pathlib import Path
10
+ from uuid import uuid4
11
+
12
+ from rlmagent.refinement.store import _lock
13
+
14
+
15
+ class Steering:
16
+ def __init__(self, path: Path | None):
17
+ self.path = path
18
+ self.memory = {}
19
+ self._mutex = threading.RLock()
20
+
21
+ def _read(self):
22
+ if self.path is None:
23
+ with self._mutex:
24
+ return copy.deepcopy(self.memory)
25
+ try:
26
+ return json.loads(self.path.read_text(encoding="utf-8"))
27
+ except FileNotFoundError:
28
+ return {}
29
+
30
+ def _write(self, records):
31
+ if self.path is None:
32
+ with self._mutex:
33
+ self.memory = records
34
+ return
35
+ temporary = self.path.with_suffix(f".{uuid4().hex}.tmp")
36
+ try:
37
+ temporary.write_text(json.dumps(records), encoding="utf-8")
38
+ temporary.replace(self.path)
39
+ finally:
40
+ temporary.unlink(missing_ok=True)
41
+
42
+ def _guard(self):
43
+ return self._mutex if self.path is None else _lock(self.path.with_suffix(".lock"))
44
+
45
+ def accept(self, text: str, identity: str | None = None, target: str = "root", *,
46
+ once: bool = False, allow_new: bool = True):
47
+ identity = identity or uuid4().hex
48
+ if (
49
+ not isinstance(identity, str)
50
+ or not 1 <= len(identity) <= 128
51
+ or not isinstance(target, str)
52
+ or not 1 <= len(target) <= 256
53
+ or not isinstance(text, str)
54
+ or not text.strip()
55
+ or len(text) > 65536
56
+ ):
57
+ raise ValueError("Invalid steering identity, target or text")
58
+
59
+ def update():
60
+ records = self._read()
61
+ if identity in records:
62
+ previous = records[identity]
63
+ if (previous["text"] != text or previous["target"] != target
64
+ or previous.get("once", False) != once):
65
+ raise ValueError("messageId was already used with different content or target")
66
+ return self.receipt(identity, previous)
67
+ if not allow_new:
68
+ raise ValueError("child is idle or finished; use send for a follow-up turn")
69
+ if len(records) >= 10000:
70
+ raise ValueError("Steering identity limit reached")
71
+ records[identity] = {
72
+ "text": text,
73
+ "target": target,
74
+ "accepted_at": time.time(),
75
+ "deliveries": {},
76
+ }
77
+ if once:
78
+ records[identity]["once"] = True
79
+ self._write(records)
80
+ return self.receipt(identity, records[identity])
81
+
82
+ with self._guard():
83
+ return update()
84
+
85
+ @staticmethod
86
+ def receipt(identity, record):
87
+ return {
88
+ "messageId": identity,
89
+ "target": record["target"],
90
+ "status": ("consumed" if record["deliveries"] else
91
+ "expired" if "expired_at" in record else "accepted"),
92
+ "deliveries": dict(record["deliveries"]),
93
+ }
94
+
95
+ def receipts(self, *, target: str | None = None):
96
+ return [self.receipt(identity, record) for identity, record in self._read().items()
97
+ if target is None or record["target"] == target]
98
+
99
+ def context(self, agent: str, *, root: bool = False, child: str | None = None):
100
+ targets = {agent}
101
+ if root:
102
+ targets.add("root")
103
+ if child is not None:
104
+ targets.add(child)
105
+ instructions, pending = [], []
106
+ for identity, record in self._read().items():
107
+ target = record["target"]
108
+ if target not in targets or "expired_at" in record:
109
+ continue
110
+ delivered = (child if target == child else agent) in record["deliveries"]
111
+ if not delivered or not record.get("once", False):
112
+ instructions.append(record["text"])
113
+ if not delivered:
114
+ pending.append(identity)
115
+ return "\n\n".join(instructions), pending
116
+
117
+ def consumed(self, identities, agent: str, request: str | None, *, child: str | None = None):
118
+ if not identities:
119
+ return
120
+
121
+ def update():
122
+ records = self._read()
123
+ for identity in identities:
124
+ record = records[identity]
125
+ record["deliveries"].setdefault(
126
+ child if record["target"] == child else agent,
127
+ {
128
+ "requestId": request,
129
+ "consumed_at": time.time(),
130
+ },
131
+ )
132
+ self._write(records)
133
+
134
+ with self._guard():
135
+ update()
136
+
137
+ def expire_child(self, child: str) -> None:
138
+ """Close undelivered one-shot instructions at the end of a child turn."""
139
+ with self._guard():
140
+ records = self._read()
141
+ pending = [record for record in records.values()
142
+ if record["target"] == child and record.get("once")
143
+ and not record["deliveries"] and "expired_at" not in record]
144
+ if pending:
145
+ finished = time.time()
146
+ for record in pending:
147
+ record["expired_at"] = finished
148
+ self._write(records)
rlmagent/stops.py ADDED
@@ -0,0 +1,62 @@
1
+ """Why a run stopped, as one word a training or evaluation pipeline can act on.
2
+
3
+ done the agent answered
4
+ max_calls the tree used up its model calls
5
+ max_cost the tree used up its spend
6
+ max_tokens the tree used up its completion or prompt tokens
7
+ max_seconds the run used up its time
8
+ agent_tokens one agent used up its completion tokens
9
+ compaction_failed the context was full and could not be compacted
10
+ no_answer the run ended without a usable answer (an empty or failed final reply)
11
+ cancelled the user or client stopped it
12
+ error anything else, with the message beside it
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ _LIMITS = (
18
+ ("calls reaches the limit", "max_calls"),
19
+ ("cost ", "max_cost"),
20
+ ("completion tokens", "max_tokens"),
21
+ ("prompt tokens", "max_tokens"),
22
+ ("tokens", "max_tokens"),
23
+ ("s reaches the limit", "max_seconds"),
24
+ )
25
+
26
+
27
+ def classify(refusal: str | None, final_text: str, error: str | None,
28
+ cancelled: bool = False) -> str:
29
+ if cancelled or (error or "").lower() in ("cancelled", "aborted"):
30
+ return "cancelled"
31
+ text = f"{refusal or ''} {error or ''}"
32
+ if "reached its limit of" in text and "completion tokens" in text:
33
+ return "agent_tokens"
34
+ if refusal:
35
+ for needle, reason in _LIMITS:
36
+ if needle in refusal:
37
+ return reason
38
+ return "max_calls"
39
+ if "compaction" in (error or "").lower():
40
+ return "compaction_failed"
41
+ if error:
42
+ return "error"
43
+ if not final_text.strip():
44
+ return "no_answer"
45
+ return "done"
46
+
47
+
48
+ def of_session(session, refusal: str | None) -> dict:
49
+ """The stop reason of the session's last turn, with the message that explains it."""
50
+ last = next((m for m in reversed(list(session.transcript))
51
+ if getattr(m, "role", "") == "assistant"), None)
52
+ text = getattr(last, "text", "") or ""
53
+ error = getattr(last, "error_message", None)
54
+ stop = getattr(last, "stop_reason", None)
55
+ reason = classify(refusal, text, error, cancelled=stop == "aborted")
56
+ typed = getattr(getattr(session, "backend", None), "last_stop_reason", None)
57
+ if typed and stop != "aborted":
58
+ reason = typed
59
+ return {"stop_reason": reason, "stop_detail": refusal or error or ""}
60
+
61
+
62
+ __all__ = ["classify", "of_session"]