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
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
|