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
rlmagent/discovery.py ADDED
@@ -0,0 +1,356 @@
1
+ """Canonical filesystem layout and automatic resource discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from rlmagent.harness.contracts.values import JValue
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Environment variable names
14
+ # ---------------------------------------------------------------------------
15
+
16
+ RLM_AGENT_HOME_ENV = "RLM_AGENT_HOME"
17
+ RLM_AGENT_SESSIONS_DIR_ENV = "RLM_AGENT_SESSIONS_DIR"
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Internal helpers
21
+ # ---------------------------------------------------------------------------
22
+
23
+ _FRONT_MATTER_RE = re.compile(r"\A---[ \t]*\n(.*?\n)---[ \t]*\n", re.DOTALL)
24
+
25
+
26
+ def _resolve_home() -> Path:
27
+ """Resolve rlm-agent's home directory from ``$RLM_AGENT_HOME`` or ``~/.rlm-agent``."""
28
+ env = os.environ.get(RLM_AGENT_HOME_ENV)
29
+ return Path(env) if env else Path.home() / ".rlm-agent"
30
+
31
+
32
+ def _resolve_sessions(home: Path) -> Path:
33
+ """Resolve the sessions directory from ``$RLM_AGENT_SESSIONS_DIR`` or ``<home>/sessions``."""
34
+ env = os.environ.get(RLM_AGENT_SESSIONS_DIR_ENV)
35
+ return Path(env) if env else home / "sessions"
36
+
37
+
38
+ def _dedupe_paths(paths: list[Path]) -> list[Path]:
39
+ """Remove duplicate paths while preserving order."""
40
+ seen: set[Path] = set()
41
+ result: list[Path] = []
42
+ for p in paths:
43
+ resolved = p.resolve()
44
+ if resolved not in seen:
45
+ seen.add(resolved)
46
+ result.append(p)
47
+ return result
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Path utility
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class RlmAgentPaths:
57
+ """Canonical filesystem locations for rlm-agent."""
58
+
59
+ home: Path = field(default_factory=_resolve_home)
60
+ project: Path = field(default_factory=Path.cwd)
61
+ # False when the project folder is not trusted: search paths then leave
62
+ # out every project-local location and only user resources load.
63
+ project_enabled: bool = True
64
+
65
+ # ── user home locations ───────────────────────────────────────────
66
+
67
+ @property
68
+ def sessions(self) -> Path:
69
+ """Session transcript and index storage (``~/.rlm-agent/sessions``)."""
70
+ return _resolve_sessions(self.home)
71
+
72
+ @property
73
+ def logs(self) -> Path:
74
+ """Log file directory (``~/.rlm-agent/logs``)."""
75
+ return self.home / "logs"
76
+
77
+ @property
78
+ def user_skills(self) -> Path:
79
+ """User-level skill definitions (``~/.rlm-agent/skills``)."""
80
+ return self.home / "skills"
81
+
82
+ @property
83
+ def user_prompts(self) -> Path:
84
+ """User-level prompt templates (``~/.rlm-agent/prompts``)."""
85
+ return self.home / "prompts"
86
+
87
+ @property
88
+ def user_themes(self) -> Path:
89
+ """User-level theme files (``~/.rlm-agent/themes``)."""
90
+ return self.home / "themes"
91
+
92
+ # ── user .agents locations ────────────────────────────────────────
93
+
94
+ @property
95
+ def agents_home(self) -> Path:
96
+ """Cross-tool agent resource directory (``~/.agents``)."""
97
+ return self.home.parent / ".agents"
98
+
99
+ @property
100
+ def agents_skills(self) -> Path:
101
+ """Cross-tool skill definitions (``~/.agents/skills``)."""
102
+ return self.agents_home / "skills"
103
+
104
+ @property
105
+ def agents_prompts(self) -> Path:
106
+ """Cross-tool prompt templates (``~/.agents/prompts``)."""
107
+ return self.agents_home / "prompts"
108
+
109
+ # ── project-local .rlm-agent locations ────────────────────────────────
110
+
111
+ @property
112
+ def project_skills(self) -> Path:
113
+ """Project-level skill definitions (``<project>/.rlm-agent/skills``)."""
114
+ return self.project / ".rlm-agent" / "skills"
115
+
116
+ @property
117
+ def project_prompts(self) -> Path:
118
+ """Project-level prompt templates (``<project>/.rlm-agent/prompts``)."""
119
+ return self.project / ".rlm-agent" / "prompts"
120
+
121
+ @property
122
+ def project_themes(self) -> Path:
123
+ """Project-level theme files (``<project>/.rlm-agent/themes``)."""
124
+ return self.project / ".rlm-agent" / "themes"
125
+
126
+ # ── project-local .agents locations ───────────────────────────────
127
+
128
+ @property
129
+ def project_agents(self) -> Path:
130
+ """Project-level cross-tool agent directory (``<project>/.agents``)."""
131
+ return self.project / ".agents"
132
+
133
+ @property
134
+ def project_agents_skills(self) -> Path:
135
+ """Project-level cross-tool skills (``<project>/.agents/skills``)."""
136
+ return self.project_agents / "skills"
137
+
138
+ @property
139
+ def project_agents_prompts(self) -> Path:
140
+ """Project-level cross-tool prompts (``<project>/.agents/prompts``)."""
141
+ return self.project_agents / "prompts"
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Default instance factory
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ def default_paths(
150
+ *,
151
+ home: Path | None = None,
152
+ project: Path | None = None,
153
+ ) -> RlmAgentPaths:
154
+ """Build a ``RlmAgentPaths`` with optional overrides for home and project."""
155
+ from rlmagent.trust import project_inputs_allowed
156
+
157
+ root = project or Path.cwd()
158
+ return RlmAgentPaths(
159
+ home=home or _resolve_home(),
160
+ project=root,
161
+ project_enabled=project_inputs_allowed(root),
162
+ )
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Resource search paths — precedence-ordered directory lists
167
+ # ---------------------------------------------------------------------------
168
+
169
+
170
+ def _project_only(paths: RlmAgentPaths, *locations: Path) -> tuple[Path, ...]:
171
+ """``locations`` when project inputs are enabled, otherwise nothing."""
172
+ return locations if paths.project_enabled else ()
173
+
174
+
175
+ def skill_search_paths(paths: RlmAgentPaths) -> list[Path]:
176
+ """Return skill directories in highest-precedence-first order."""
177
+ from rlmagent.packages import resource_dirs
178
+
179
+ return _dedupe_paths([
180
+ *_project_only(paths, paths.project_agents_skills, paths.project_skills),
181
+ paths.agents_skills,
182
+ paths.user_skills,
183
+ *resource_dirs("skills", paths=paths),
184
+ ])
185
+
186
+
187
+ def prompt_search_paths(paths: RlmAgentPaths) -> list[Path]:
188
+ """Return prompt directories in highest-precedence-first order."""
189
+ from rlmagent.packages import resource_dirs
190
+
191
+ return _dedupe_paths([
192
+ *_project_only(paths, paths.project_agents_prompts, paths.project_prompts),
193
+ paths.agents_prompts,
194
+ paths.user_prompts,
195
+ *resource_dirs("prompts", paths=paths),
196
+ ])
197
+
198
+
199
+ def theme_search_paths(paths: RlmAgentPaths) -> list[Path]:
200
+ """Return theme directories in highest-precedence-first order."""
201
+ from rlmagent.packages import resource_dirs
202
+
203
+ return _dedupe_paths([
204
+ *_project_only(paths, paths.project_themes),
205
+ paths.user_themes,
206
+ *resource_dirs("themes", paths=paths),
207
+ ])
208
+
209
+
210
+ def resource_search_paths(
211
+ paths: RlmAgentPaths,
212
+ resource_type: str,
213
+ *,
214
+ rlm_agent_only: bool = False,
215
+ ) -> list[Path]:
216
+ """Return search directories for an arbitrary resource type."""
217
+ dirs: list[Path] = []
218
+ if paths.project_enabled:
219
+ if not rlm_agent_only:
220
+ dirs.append(paths.project / ".agents" / resource_type)
221
+ dirs.append(paths.project / ".rlm-agent" / resource_type)
222
+ if not rlm_agent_only:
223
+ dirs.append(paths.agents_home / resource_type)
224
+ dirs.append(paths.home / resource_type)
225
+ return _dedupe_paths(dirs)
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Resource discovery diagnostics
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ @dataclass(frozen=True, slots=True)
234
+ class ResourceDiagnostic:
235
+ """Non-fatal diagnostic emitted during resource discovery."""
236
+
237
+ resource_name: str
238
+ winner: Path
239
+ overridden: Path
240
+ message: str
241
+
242
+
243
+ def collect_override_diagnostics(
244
+ discovered: dict[str, Path],
245
+ new_resources: dict[str, Path],
246
+ source_dir: Path,
247
+ ) -> list[ResourceDiagnostic]:
248
+ """Produce diagnostics when resources in *new_resources* would be
249
+ overridden by existing entries in *discovered*."""
250
+ diagnostics: list[ResourceDiagnostic] = []
251
+ for name, new_path in new_resources.items():
252
+ if name in discovered:
253
+ diagnostics.append(ResourceDiagnostic(
254
+ resource_name=name,
255
+ winner=discovered[name],
256
+ overridden=new_path,
257
+ message=(
258
+ f"Resource {name!r} from {source_dir} overridden by "
259
+ f"higher-precedence copy at {discovered[name]}"
260
+ ),
261
+ ))
262
+ return diagnostics
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # Markdown resource helpers
267
+ # ---------------------------------------------------------------------------
268
+
269
+
270
+ @dataclass(frozen=True, slots=True)
271
+ class MarkdownResource:
272
+ """A parsed markdown file with optional frontmatter metadata."""
273
+
274
+ path: Path
275
+ body: str
276
+ metadata: dict[str, str]
277
+
278
+ @property
279
+ def name(self) -> str:
280
+ """The ``name`` from frontmatter, or the file/directory stem."""
281
+ return self.metadata.get("name", self.path.stem)
282
+
283
+ @property
284
+ def description(self) -> str:
285
+ """The ``description`` from frontmatter, or the first non-heading paragraph."""
286
+ explicit = self.metadata.get("description")
287
+ if explicit:
288
+ return explicit
289
+ return derive_description(self.body)
290
+
291
+ def metadata_json(self) -> dict[str, JValue]:
292
+ """Return metadata as a JSON-compatible dictionary."""
293
+ return dict(self.metadata)
294
+
295
+
296
+ def parse_front_matter(text: str) -> tuple[dict[str, str], str]:
297
+ """Split optional YAML-like frontmatter from the markdown body."""
298
+ match = _FRONT_MATTER_RE.match(text)
299
+ if match is None:
300
+ return {}, text
301
+
302
+ raw = match.group(1)
303
+ body = text[match.end():]
304
+ meta: dict[str, str] = {}
305
+ for line in raw.splitlines():
306
+ stripped = line.strip()
307
+ if not stripped or stripped.startswith("#"):
308
+ continue
309
+ colon = stripped.find(":")
310
+ if colon < 1:
311
+ continue
312
+ key = stripped[:colon].strip()
313
+ value = stripped[colon + 1:].strip()
314
+ if len(value) >= 2 and value[0] in ('"', "'") and value[-1] == value[0]:
315
+ value = value[1:-1]
316
+ meta[key] = value
317
+ return meta, body
318
+
319
+
320
+ def derive_description(body: str) -> str:
321
+ """Extract the first non-heading, non-blank paragraph as a description."""
322
+ for line in body.splitlines():
323
+ stripped = line.strip()
324
+ if not stripped:
325
+ continue
326
+ if stripped.startswith("#"):
327
+ continue
328
+ if stripped.startswith("---"):
329
+ continue
330
+ return stripped
331
+ return ""
332
+
333
+
334
+ def parse_markdown(path: Path) -> MarkdownResource:
335
+ """Read and parse a markdown file into a ``MarkdownResource``."""
336
+ raw = path.read_text(encoding="utf-8")
337
+ metadata, body = parse_front_matter(raw)
338
+ return MarkdownResource(path=path, body=body, metadata=metadata)
339
+
340
+
341
+ __all__ = [
342
+ "RLM_AGENT_HOME_ENV",
343
+ "RLM_AGENT_SESSIONS_DIR_ENV",
344
+ "RlmAgentPaths",
345
+ "MarkdownResource",
346
+ "ResourceDiagnostic",
347
+ "collect_override_diagnostics",
348
+ "default_paths",
349
+ "derive_description",
350
+ "parse_front_matter",
351
+ "parse_markdown",
352
+ "prompt_search_paths",
353
+ "resource_search_paths",
354
+ "skill_search_paths",
355
+ "theme_search_paths",
356
+ ]
@@ -0,0 +1,66 @@
1
+ """Portable step records for execution-log viewers."""
2
+
3
+ from datetime import UTC, datetime
4
+
5
+
6
+ def records(event, history):
7
+ kind = event["kind"]
8
+ mapping = {
9
+ "run_started": "agent_start",
10
+ "run_failed": "agent_end",
11
+ "run_completed": "agent_end",
12
+ "code_generated": "code_generated",
13
+ "step": "execution_result",
14
+ }
15
+ if kind not in mapping:
16
+ return []
17
+ agent = event.get("run_id", "root")
18
+ start = next(
19
+ (r for r in reversed(history) if r["kind"] == "run_started" and r.get("run_id") == agent),
20
+ {},
21
+ )
22
+ row = {
23
+ "level": 30,
24
+ "time": datetime.fromtimestamp(event["timestamp"], UTC).isoformat(),
25
+ "event_type": mapping[kind],
26
+ "run_id": agent,
27
+ "depth": start.get("depth", 0),
28
+ }
29
+ if start.get("parent_run_id"):
30
+ row["parent_run_id"] = start["parent_run_id"]
31
+ # Keep separate root executions distinct when a saved session appends turns.
32
+ start_index = next(
33
+ (i for i in range(len(history) - 1, -1, -1) if history[i] is start), len(history) - 1
34
+ )
35
+ root = next(
36
+ (
37
+ item
38
+ for item in reversed(history[: start_index + 1])
39
+ if item["kind"] == "run_started" and not item.get("parent_run_id")
40
+ ),
41
+ None,
42
+ )
43
+ if root is not None and "timestamp" in root:
44
+ prefix = f"run-{int(root['timestamp'] * 1_000_000)}:"
45
+ row["run_id"] = prefix + agent
46
+ if row.get("parent_run_id"):
47
+ row["parent_run_id"] = prefix + row["parent_run_id"]
48
+ for field in ("step", "code", "output", "usage", "reasoning", "timestamps"):
49
+ if field in event:
50
+ row[field] = event[field]
51
+ if kind == "step":
52
+ row["hasError"] = bool(event.get("error", False))
53
+ if kind == "run_failed":
54
+ row["hasError"] = True
55
+ row["error"] = event.get("error")
56
+ result = []
57
+ if kind == "run_completed":
58
+ result.append(
59
+ {
60
+ **row,
61
+ "event_type": "final_result",
62
+ "result": event.get("result", event.get("output")),
63
+ }
64
+ )
65
+ result.append(row)
66
+ return result
rlmagent/factory.py ADDED
@@ -0,0 +1,189 @@
1
+ """Run an explicit sequence of stages, each governed by its own contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import hashlib
8
+ import json
9
+ import re
10
+ import sys
11
+ import uuid
12
+ from pathlib import Path
13
+ from copy import deepcopy
14
+
15
+ from rlmagent.contract import ContractError, load_contract, parse_contract
16
+ from rlmagent.contract_run import build_contract_session, execute_session
17
+
18
+ HANDOFF_LIMIT = 20_000
19
+ _NAME = re.compile(r"[a-z0-9][\w-]{0,63}", re.ASCII | re.IGNORECASE)
20
+ _RECIPES = {
21
+ "build": (
22
+ "Inspect the project and identify the smallest changes needed for the task.",
23
+ "Implement the changes from the prior analysis and run targeted verification.",
24
+ ),
25
+ "review": (
26
+ "Inspect the current diff for behavior regressions and list concrete findings.",
27
+ "Verify the prior findings against the source and tests. Report only reproducible issues.",
28
+ ),
29
+ "release-review": (
30
+ "Identify release requirements, dependency ordering, and outstanding validation.",
31
+ "Check the available evidence and report release blockers with concrete next steps.",
32
+ ),
33
+ }
34
+
35
+
36
+ def template(name: str) -> Path:
37
+ from rlmagent.plugins import rlm_agent_home
38
+
39
+ if not _NAME.fullmatch(name):
40
+ raise ContractError("invalid factory name")
41
+ return rlm_agent_home() / "factories" / f"{name}.json"
42
+
43
+
44
+ def load_stages(path: Path, *, credentials: bool = True) -> list:
45
+ manifest = json.loads(path.read_text(encoding="utf-8"))
46
+ if not isinstance(manifest, dict) or manifest.keys() != {"format", "stages"}:
47
+ raise ContractError("factory: expected format and stages")
48
+ stages = manifest["stages"]
49
+ if manifest["format"] != "rlmagent.factory/1" or not isinstance(stages, list) or not stages:
50
+ raise ContractError("factory: expected rlmagent.factory/1 with a nonempty stages list")
51
+ loaded = []
52
+ traces = set()
53
+ for index, stage in enumerate(stages, 1):
54
+ if not isinstance(stage, dict) or not isinstance(stage.get("contract"), (str, dict)):
55
+ raise ContractError(f"factory stage {index}: expected a contract")
56
+ if stage.keys() == {"contract", "prompt"}:
57
+ prompts = stage["prompt"]
58
+ valid = isinstance(prompts, str) and prompts.strip()
59
+ elif stage.keys() == {"contract", "resident"}:
60
+ prompts = stage["resident"]
61
+ valid = (isinstance(prompts, list) and prompts
62
+ and all(isinstance(text, str) and text.strip() for text in prompts))
63
+ else:
64
+ valid = False
65
+ if not valid:
66
+ raise ContractError(f"factory stage {index}: expected a prompt or a resident prompt list")
67
+ spec = stage["contract"]
68
+ contract = load_contract(path.parent / spec) if isinstance(spec, str) else parse_contract(spec)
69
+ trace = Path(contract.trace.file).resolve()
70
+ if trace in traces:
71
+ raise ContractError(f"factory stage {index}: each stage needs a distinct trace file")
72
+ traces.add(trace)
73
+ if credentials:
74
+ contract.api_key()
75
+ loaded.append((contract, prompts))
76
+ return loaded
77
+
78
+
79
+ def _checkpoint(path: Path, record: dict) -> None:
80
+ temporary = path.with_suffix(f".{uuid.uuid4().hex}.tmp")
81
+ try:
82
+ temporary.write_text(json.dumps(record), encoding="utf-8")
83
+ temporary.replace(path)
84
+ finally:
85
+ temporary.unlink(missing_ok=True)
86
+
87
+
88
+ def _save_template(name: str, stages) -> None:
89
+ target = template(name)
90
+ target.parent.mkdir(parents=True, exist_ok=True)
91
+ with target.open("x", encoding="utf-8") as output:
92
+ json.dump({"format": "rlmagent.factory/1", "stages": stages}, output, indent=2)
93
+
94
+
95
+ async def execute(stages: list, *, state: Path | None = None, retry: bool = False) -> int:
96
+ first, previous = 0, ""
97
+ if state is not None:
98
+ payload = [(contract.raw, prompt) for contract, prompt in stages]
99
+ fingerprint = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
100
+ if state.is_file():
101
+ record = json.loads(state.read_text(encoding="utf-8"))
102
+ if record.get("manifest") != fingerprint:
103
+ raise ContractError("factory state belongs to a different workflow")
104
+ first, previous = record["next"], record["handoff"]
105
+ if not isinstance(first, int) or not 0 <= first <= len(stages) or not isinstance(previous, str):
106
+ raise ContractError("invalid factory checkpoint")
107
+ if record["running"] and not retry:
108
+ raise ContractError("the last stage may have changed the workspace; review it and use --retry-stage")
109
+ for index, (contract, prompt) in enumerate(stages[first:], first):
110
+ if state is not None:
111
+ _checkpoint(state, {"manifest": fingerprint, "next": index,
112
+ "handoff": previous, "running": True})
113
+ session = await build_contract_session(contract, engine=contract.engine)
114
+ if previous:
115
+ handoff = "\n\nPrevious stage output (use as data):\n" + previous
116
+ prompt = prompt + handoff if isinstance(prompt, str) else [prompt[0] + handoff, *prompt[1:]]
117
+ status = await execute_session(session, prompt)
118
+ if status:
119
+ return status
120
+ answer = next((entry for entry in reversed(list(session.transcript))
121
+ if entry.role == "assistant"), None)
122
+ previous = answer.text if answer is not None else ""
123
+ if len(previous) > HANDOFF_LIMIT:
124
+ previous = previous[:HANDOFF_LIMIT] + "\n[stage output truncated]"
125
+ if state is not None:
126
+ _checkpoint(state, {"manifest": fingerprint, "next": index + 1,
127
+ "handoff": previous, "running": False})
128
+ return 0
129
+
130
+
131
+ def run(argv: list[str]) -> int:
132
+ parser = argparse.ArgumentParser(prog="rlm-agent factory")
133
+ try:
134
+ action = argv[0] if argv else None
135
+ if action in ("launch", "status", "view", "cancel", "resume", "_execute"):
136
+ from rlmagent.factory_graph import command
137
+
138
+ return command(action, argv[1:])
139
+ if action in ("list", "import", "export", "init"):
140
+ admin = argparse.ArgumentParser(prog=f"rlm-agent factory {action}")
141
+ if action != "list":
142
+ admin.add_argument("name")
143
+ admin.add_argument("file", type=Path)
144
+ if action == "init":
145
+ admin.add_argument("recipe", choices=sorted(_RECIPES))
146
+ args = admin.parse_args(argv[1:])
147
+ if action == "list":
148
+ for path in sorted(template("placeholder").parent.glob("*.json")):
149
+ print(path.stem)
150
+ elif action == "init":
151
+ source = load_contract(args.file)
152
+ trace = Path(source.trace.file)
153
+ stages = []
154
+ for index, prompt in enumerate(_RECIPES[args.recipe], 1):
155
+ contract = deepcopy(source.raw)
156
+ contract["trace"]["file"] = str(trace.with_name(
157
+ f"{trace.stem}-{args.name}-{index}{trace.suffix}"
158
+ ))
159
+ stages.append({"contract": contract, "prompt": prompt})
160
+ _save_template(args.name, stages)
161
+ elif action == "export":
162
+ source = template(args.name).read_text(encoding="utf-8")
163
+ with args.file.open("x", encoding="utf-8") as output:
164
+ output.write(source)
165
+ else:
166
+ stages = load_stages(args.file, credentials=False)
167
+ _save_template(args.name, [
168
+ {"contract": contract.raw,
169
+ ("prompt" if isinstance(prompt, str) else "resident"): prompt}
170
+ for contract, prompt in stages
171
+ ])
172
+ return 0
173
+ parser.add_argument("manifest")
174
+ parser.add_argument("--state", type=Path)
175
+ parser.add_argument("--retry-stage", action="store_true")
176
+ args = parser.parse_args(argv[1:] if action == "run" else argv)
177
+ manifest = template(args.manifest) if action == "run" else Path(args.manifest)
178
+ stages = load_stages(manifest)
179
+ if args.state is None:
180
+ return asyncio.run(execute(stages))
181
+ from rlmagent.harness.session.lease import acquire
182
+
183
+ if args.state.resolve() in {Path(contract.trace.file).resolve() for contract, _ in stages}:
184
+ raise ContractError("factory state needs a different file from the stage traces")
185
+ with acquire(args.state.parent, args.state.name):
186
+ return asyncio.run(execute(stages, state=args.state, retry=args.retry_stage))
187
+ except (RuntimeError, OSError, ValueError) as exc:
188
+ print(f"rlm-agent: {exc}", file=sys.stderr)
189
+ return 2