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,448 @@
1
+ """Single-job refinement with activation at session boundaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import json
8
+ from contextlib import suppress
9
+ from pathlib import Path
10
+ from uuid import uuid4
11
+
12
+ from rlmagent.refinement.engine import evidence_text, review
13
+ from rlmagent.refinement.store import HarnessStore, skill_parts
14
+ from rlmagent.skillset import Skill
15
+
16
+
17
+ #: A saved note is sent with every request, so a long one is paid for every time.
18
+ DIGEST_CHARS = 400
19
+ AUTO_COOLDOWN_TURNS = 3
20
+ BUSY_PHASES = ("reviewing", "awaiting review", "ready", "applying")
21
+
22
+
23
+ class Signal:
24
+ def __init__(self, enabled=None):
25
+ self.cancelled = False
26
+ self.enabled = enabled
27
+
28
+ def is_cancelled(self):
29
+ return self.cancelled or (self.enabled is not None and not self.enabled())
30
+
31
+
32
+ class Refinement:
33
+ def __init__(self, session, store: HarnessStore):
34
+ self.session, self.store = session, store
35
+ self.active = store.read()
36
+ self.base_system = session._system
37
+ self.base_backend = getattr(getattr(session, "backend", None), "instructions", None)
38
+ self.installed = list(session._skills)
39
+ self.task = None
40
+ self.signal = Signal(self.enabled)
41
+ self.generation = 0
42
+ self.phase = "idle"
43
+ self.detail = "No manual review has been requested."
44
+ self.job_id = None
45
+ self.draft = None
46
+ self.automatic = "off"
47
+ self._automatic_job = False
48
+ self._pending_evidence = None
49
+ self._settled_turns = 0
50
+ self._next_review_turn = 0
51
+ # A proposal that changes guidance waits for /refine apply before it is saved.
52
+ self.review_first = True
53
+ self._apply_active()
54
+
55
+ def writable(self):
56
+ if not self.enabled():
57
+ return False
58
+ if self.session.plan_mode:
59
+ return False
60
+ improver = getattr(self.session, "improver", None)
61
+ return getattr(improver, "writable", True)
62
+
63
+ def enabled(self) -> bool:
64
+ home = getattr(self, "home", None)
65
+ if home is None:
66
+ return getattr(self, "_enabled", True)
67
+ try:
68
+ value = json.loads((home / "refinement.json").read_text(encoding="utf-8"))
69
+ except FileNotFoundError:
70
+ return True
71
+ if not isinstance(value, dict) or value.keys() != {"enabled"} or type(value["enabled"]) is not bool:
72
+ raise ValueError("Invalid global refinement preference")
73
+ return value["enabled"]
74
+
75
+ def status(self):
76
+ identity = f" {self.job_id}" if self.job_id else ""
77
+ return f"Refinement{identity}: {self.phase}. {self.detail}"
78
+
79
+ async def command(self, argument):
80
+ mode = argument.strip()
81
+ if mode == "auto" or mode.startswith("auto "):
82
+ selection = mode.removeprefix("auto").strip()
83
+ if not selection:
84
+ return f"Automatic harness refinement: {self.automatic}."
85
+ if selection not in ("review", "apply", "off"):
86
+ return "Use /refine auto review|apply|off."
87
+ if selection != "off" and not self.writable():
88
+ return "Automatic refinement requires writable harness guidance."
89
+ if self._automatic_job and selection != self.automatic:
90
+ self.cancel()
91
+ self.automatic = selection
92
+ if selection == "off":
93
+ self._pending_evidence = None
94
+ return f"Automatic harness refinement: {selection}."
95
+ if mode.startswith("scope"):
96
+ words = mode.split()
97
+ if len(words) == 1:
98
+ return "Refinement scope: " + getattr(self, "scope", "project")
99
+ if len(words) != 2 or words[1] not in ("session", "project"):
100
+ return "Use /refine scope session|project."
101
+ if self.session.harness.active or not self.writable():
102
+ return "Changing scope requires an idle writable session."
103
+ self.cancel()
104
+ from rlmagent.refinement.scoped import ScopedStore
105
+
106
+ if not hasattr(self, "project_store"):
107
+ self.project_store = self.store
108
+ if words[1] == "session":
109
+ folder = (project_folder(self.home, self.session.cwd) / "sessions" /
110
+ self.session.session_id) if hasattr(self, "home") and self.store.path else None
111
+ path = folder / "state.json" if folder else None
112
+ self.store = ScopedStore(path, self.session, self.active)
113
+ self.store.initialize()
114
+ else:
115
+ self.store = self.project_store
116
+ self.scope = words[1]
117
+ self.active = self.store.read()
118
+ if hasattr(self, "home") and self.project_store.path is not None:
119
+ folder = project_folder(self.home, self.session.cwd) / "sessions" / self.session.session_id
120
+ folder.mkdir(parents=True, exist_ok=True)
121
+ preference = folder / "scope.json"
122
+ temporary = preference.with_suffix(f".{uuid4().hex}.tmp")
123
+ try:
124
+ temporary.write_text(json.dumps({"scope": self.scope}), encoding="utf-8")
125
+ temporary.replace(preference)
126
+ finally:
127
+ temporary.unlink(missing_ok=True)
128
+ self._apply_active()
129
+ return "Refinement scope: " + self.scope
130
+ if mode in ("on", "off"):
131
+ self.cancel()
132
+ self._enabled = mode == "on"
133
+ if home := getattr(self, "home", None):
134
+ from rlmagent.refinement.store import _lock
135
+
136
+ home.mkdir(parents=True, exist_ok=True)
137
+ with _lock(home / "refinement.lock"):
138
+ path = home / "refinement.json"
139
+ staged = path.with_suffix(f".{uuid4().hex}.tmp")
140
+ try:
141
+ staged.write_text(json.dumps({"enabled": self._enabled}), encoding="utf-8")
142
+ staged.replace(path)
143
+ finally:
144
+ staged.unlink(missing_ok=True)
145
+ self._apply_active()
146
+ return "Harness refinements " + ("enabled." if self._enabled else "disabled.")
147
+ if mode.startswith("rollback "):
148
+ if self.session.harness.active or not self.writable():
149
+ return "Rollback requires an idle writable session with refinements enabled."
150
+ from rlmagent.refinement.store import ChangeNote
151
+
152
+ try:
153
+ revision = int(mode.split()[1])
154
+ baseline = self.store.read()
155
+ target = self.store.version(revision)
156
+ candidate = baseline.model_copy(deep=True)
157
+ candidate.entries = target.entries
158
+ candidate.revision += 1
159
+ candidate.history.append(ChangeNote(
160
+ id=uuid4().hex, summary=f"Restore guidance from revision {revision}",
161
+ evidence="User requested rollback", expected="Restore the selected saved guidance",
162
+ edits=[{"action": "rollback", "revision": revision}],
163
+ ))
164
+ self.cancel()
165
+ self.active = self.store.commit(baseline, candidate)
166
+ self._apply_active()
167
+ return f"Restored guidance from revision {revision}."
168
+ except (ValueError, IndexError) as exc:
169
+ return str(exc)
170
+ if not self.enabled() and mode not in ("status", "cancel"):
171
+ return "Harness refinements are disabled. Use /refine on to enable them."
172
+ if argument.strip() == "status":
173
+ return self.status()
174
+ if argument.strip() == "cancel":
175
+ self.cancel()
176
+ return self.status()
177
+ if argument.strip() == "show":
178
+ if self.draft is None:
179
+ return "No proposal is waiting. " + self.status()
180
+ return proposal_diff(self.draft.baseline, self.draft.state)
181
+ if argument.strip() == "apply":
182
+ if self.phase != "awaiting review":
183
+ return "No proposal is waiting for review. " + self.status()
184
+ self.phase, self.detail = "ready", "Approved proposal awaits a turn boundary."
185
+ if not self.session.harness.active:
186
+ self.at_boundary()
187
+ return self.status()
188
+ words = argument.split()
189
+ evidence = getattr(self.session, "evidence", None)
190
+ if words[:1] == ["evidence"]:
191
+ return evidence.report() if evidence is not None else "No evidence is kept here."
192
+ if words[:1] == ["rate"]:
193
+ if evidence is None or len(words) != 3:
194
+ return "Use /refine rate <change> better|same|worse."
195
+ return evidence.rate(words[1], words[2])
196
+ if not self.writable():
197
+ return "Refinement is unavailable while guidance is read-only."
198
+ if self.phase in BUSY_PHASES:
199
+ return self.status() + " Finish or cancel this review before starting another."
200
+ return self._start_review(argument.strip() or None, evidence_text(list(self.session.transcript)))
201
+
202
+ def compacted(self, transcript) -> None:
203
+ if self.automatic != "off" and self.writable():
204
+ self._pending_evidence = evidence_text(transcript)
205
+ self._maybe_start_review()
206
+
207
+ def settled(self) -> None:
208
+ self._settled_turns += 1
209
+ self._maybe_start_review()
210
+
211
+ def _maybe_start_review(self) -> None:
212
+ if (
213
+ self._pending_evidence is None or self.session.harness.active
214
+ or self.phase in BUSY_PHASES or self._settled_turns < self._next_review_turn
215
+ or not self.writable()
216
+ ):
217
+ return
218
+ evidence, self._pending_evidence = self._pending_evidence, None
219
+ self._next_review_turn = self._settled_turns + AUTO_COOLDOWN_TURNS
220
+ self._start_review("Review the run that led to compaction.", evidence, automatic=True)
221
+
222
+ def _start_review(self, focus, evidence, *, automatic=False):
223
+ baseline = self.store.read()
224
+ provider, model = self.session._provider, self.session.model
225
+ self.generation += 1
226
+ generation = self.generation
227
+ self.signal = Signal(self.enabled)
228
+ self.job_id = uuid4().hex[:12]
229
+ self.phase, self.detail = "reviewing", "Starting a bounded harness review."
230
+ self._automatic_job = automatic
231
+ review_first = self.automatic != "apply" if automatic else self.review_first
232
+ signal = self.signal
233
+
234
+ def progress(text):
235
+ if generation == self.generation:
236
+ self._progress(text)
237
+
238
+ async def run():
239
+ try:
240
+ candidate = await asyncio.wait_for(
241
+ review(
242
+ provider,
243
+ model,
244
+ baseline,
245
+ evidence,
246
+ focus,
247
+ signal,
248
+ progress,
249
+ ),
250
+ timeout=300,
251
+ )
252
+ if generation == self.generation and not self.signal.is_cancelled():
253
+ self.draft = candidate
254
+ if review_first and candidate.changed:
255
+ self.phase, self.detail = "awaiting review", (
256
+ "/refine show lists the proposed changes; /refine apply saves "
257
+ "them; /refine cancel drops them.")
258
+ return
259
+ self.phase, self.detail = "ready", "Validated proposal awaits a turn boundary."
260
+ if not self.session.harness.active:
261
+ self.at_boundary()
262
+ except asyncio.CancelledError:
263
+ if generation == self.generation:
264
+ self.phase, self.detail = "cancelled", "No staged changes were committed."
265
+ except Exception as error: # provider failures are visible through status
266
+ if generation == self.generation:
267
+ self.phase, self.detail = "failed", str(error)[:1000]
268
+
269
+ self.task = asyncio.create_task(run(), name=f"harness-review-{self.job_id}")
270
+ return self.status()
271
+
272
+ def _progress(self, text):
273
+ self.detail = text
274
+
275
+ def cancel(self):
276
+ self.generation += 1
277
+ self.signal.cancelled = True
278
+ self.draft = None
279
+ self._pending_evidence = None
280
+ if self.task is not None and not self.task.done():
281
+ self.task.cancel()
282
+ if self.phase in BUSY_PHASES:
283
+ self.phase, self.detail = "cancelled", "Discarded the pending review."
284
+
285
+ async def close(self):
286
+ self.cancel()
287
+ if self.task is not None:
288
+ with suppress(asyncio.CancelledError):
289
+ await self.task
290
+
291
+ def at_boundary(self):
292
+ if not self.enabled():
293
+ self.cancel()
294
+ self._apply_active()
295
+ return
296
+ if self.session.harness.active:
297
+ return
298
+ if self.phase == "ready":
299
+ if not self.writable():
300
+ self.cancel()
301
+ return
302
+ candidate = self.draft
303
+ self.draft = None
304
+ self.phase = "applying"
305
+ try:
306
+ if candidate.changed:
307
+ self.active = self.store.commit(candidate.baseline, candidate.state)
308
+ note = self.active.history[-1]
309
+ evidence = getattr(self.session, "evidence", None)
310
+ if evidence is not None:
311
+ evidence.saved(note.id[:12], "refine",
312
+ sorted({edit["id"] for edit in note.edits}), note.summary)
313
+ else:
314
+ self.active = self.store.read()
315
+ self.phase = "completed"
316
+ self.detail = (
317
+ f"Active harness revision {self.active.revision}. "
318
+ "Expected benefits have not been benchmarked."
319
+ )
320
+ except Exception as error:
321
+ self.phase, self.detail = "failed", str(error)[:1000]
322
+ # Reload only at a boundary, including changes committed by other sessions.
323
+ try:
324
+ latest = self.store.read()
325
+ if latest != self.active:
326
+ self.active = latest
327
+ self._apply_active()
328
+ except Exception as error:
329
+ self.phase, self.detail = "failed", str(error)[:1000]
330
+
331
+ def render(self, base: str, *, ignore_gate=False) -> str:
332
+ if not ignore_gate and not self.enabled():
333
+ return base
334
+ parts = []
335
+ for entry in self.active.entries.values():
336
+ if entry.kind == "skill":
337
+ description, _ = skill_parts(entry.id, entry.content)
338
+ parts.append(f"Workflow {entry.id}: {description}")
339
+ else:
340
+ parts.append(f"{entry.kind} {entry.id} ({entry.title}):\n{_digest(entry)}")
341
+ if not parts:
342
+ return base
343
+ return base + (
344
+ "\n\nSaved working guidance (supplements the base instructions):\n"
345
+ + "\n\n".join(parts)
346
+ + "\nRead a selected workflow with await guidance(id), or invoke "
347
+ "/skill:<id>. Read its full procedure before using it."
348
+ )
349
+
350
+ def filter_system(self, text):
351
+ if self.enabled():
352
+ return text
353
+ supplemental = self.render("", ignore_gate=True)
354
+ return text.replace(supplemental, "") if supplemental else text
355
+
356
+ def read_guidance(self, entry_id=None):
357
+ if not self.enabled():
358
+ raise ValueError("Harness refinements are disabled")
359
+ if entry_id is not None:
360
+ return self.active.entries[entry_id].model_dump()
361
+ return [
362
+ {"id": entry.id, "kind": entry.kind, "title": entry.title}
363
+ for entry in self.active.entries.values()
364
+ ]
365
+
366
+ def _apply_active(self):
367
+ self.session._system = self.render(self.base_system)
368
+ if self.base_backend is not None:
369
+ self.session.backend.instructions = self.render(self.base_backend)
370
+ self.session._apply_plan_mode()
371
+ skills = {skill.name: skill for skill in self.installed}
372
+ for entry in self.active.entries.values() if self.enabled() else ():
373
+ if entry.kind == "skill":
374
+ description, body = skill_parts(entry.id, entry.content)
375
+ skills[entry.id] = Skill(
376
+ entry.id, description, body, self.store.path or Path("<ephemeral-harness>")
377
+ )
378
+ self.session._skills = list(skills.values())
379
+
380
+ def transfer(self, session):
381
+ """Reattach guidance when the frontend replaces its session object."""
382
+ session._system = self.base_system
383
+ if hasattr(self, "home"):
384
+ return attach(session, self.home, persist=self.store.path is not None)
385
+ store = self.store if session.cwd == self.session.cwd else HarnessStore(None)
386
+ session.refinement = Refinement(session, store)
387
+ return session.refinement
388
+
389
+
390
+ def proposal_diff(before, after) -> str:
391
+ """Each entry a proposal adds, changes or removes, with a unified diff of its text."""
392
+ import difflib
393
+
394
+ parts = []
395
+ for entry_id in sorted(set(before.entries) | set(after.entries)):
396
+ old, new = before.entries.get(entry_id), after.entries.get(entry_id)
397
+ if old == new:
398
+ continue
399
+ verb = "add" if old is None else "remove" if new is None else "change"
400
+ shown = new or old
401
+ parts.append(f"{verb} {shown.kind} {entry_id}: {shown.title}")
402
+ lines = difflib.unified_diff(
403
+ (old.content if old else "").splitlines(), (new.content if new else "").splitlines(),
404
+ fromfile=f"a/{entry_id}", tofile=f"b/{entry_id}", lineterm="")
405
+ parts.append("\n".join(lines))
406
+ return "\n".join(parts) or "The proposal changes nothing."
407
+
408
+
409
+ def _digest(entry) -> str:
410
+ """The entry as it goes in every prompt: short ones whole, long ones the start of them."""
411
+ if len(entry.content) <= DIGEST_CHARS:
412
+ return entry.content
413
+ head = entry.content[:DIGEST_CHARS].rstrip()
414
+ return f'{head}... (full text: await guidance("{entry.id}"))'
415
+
416
+
417
+ def project_folder(home: Path, cwd: str) -> Path:
418
+ project = hashlib.sha256(str(Path(cwd).resolve()).encode()).hexdigest()[:24]
419
+ return home / "harness" / project
420
+
421
+
422
+ def attach(session, home: Path, *, persist: bool):
423
+ path = project_folder(home, session.cwd) / "state.json" if persist else None
424
+ controller = Refinement(session, HarnessStore(path))
425
+ controller.home = home
426
+ controller.project_store = controller.store
427
+ controller.scope = "project"
428
+ if persist:
429
+ from rlmagent.refinement.scoped import ScopedStore
430
+
431
+ folder = project_folder(home, session.cwd) / "sessions" / session.session_id
432
+ try:
433
+ selected = json.loads((folder / "scope.json").read_text(encoding="utf-8"))["scope"]
434
+ except FileNotFoundError:
435
+ selected = "project"
436
+ if selected == "session":
437
+ controller.store = ScopedStore(folder / "state.json", session, controller.active)
438
+ controller.active = controller.store.read()
439
+ controller.scope = "session"
440
+ controller._apply_active()
441
+ session.refinement = controller
442
+ tree = getattr(session, "agent_tree", None)
443
+ if tree is not None:
444
+ tree.refinement = controller
445
+ if hasattr(tree, "system_for"):
446
+ original = tree.system_for
447
+ tree.system_for = lambda node, tools: controller.render(original(node, tools))
448
+ return controller
@@ -0,0 +1,220 @@
1
+ """An isolated model conversation with two non-executable harness operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from uuid import uuid4
8
+
9
+ from pydantic import Field
10
+
11
+ from rlmagent.harness.contracts.tooling import ToolOutcome, ToolSpec
12
+ from rlmagent.harness.contracts.transcript import HumanEntry, TextSegment, ToolOutcomeEntry
13
+ from rlmagent.harness.provider.wire import StreamCloseEvent, StreamFaultEvent
14
+ from rlmagent.refinement.store import ChangeNote, Guidance, Record, Snapshot
15
+
16
+ STEP_LIMIT = 12
17
+ EDIT_LIMIT = 8
18
+ INPUT_LIMIT = 80_000
19
+ POLICY = """Review the supplied task record for reusable guidance. You have no execution
20
+ environment and cannot modify the project, installed skills, or base instructions.
21
+ First read the editable harness with guidance_inspect. Propose narrowly supported
22
+ changes through guidance_edit. A workflow needs a complete Markdown document with
23
+ YAML name and description fields. Use prompt for concise behavioral guidance,
24
+ skill for a repeatable procedure, and subagent for delegation instructions.
25
+ Avoid preserving secrets, incidental task output, or guesses. An empty change set
26
+ is acceptable. Finish with a JSON object containing summary, evidence, and expected.
27
+ Expected describes a prediction, not a measured performance improvement.
28
+ For text-only model adapters, request one operation per reply as JSON:
29
+ {"tool":"guidance_inspect","arguments":{}} or
30
+ {"tool":"guidance_edit","arguments":{"action":"create","entry":
31
+ {"id":"check-build","kind":"prompt","title":"Build check","content":"..."}}}.
32
+ Use action update with a complete replacement entry; delete uses id instead.
33
+ Do not enclose JSON in a code fence. No other operations are available."""
34
+
35
+
36
+ class Verdict(Record):
37
+ summary: str = Field(min_length=1, max_length=2000)
38
+ evidence: str = Field(min_length=1, max_length=8000)
39
+ expected: str = Field(min_length=1, max_length=2000)
40
+
41
+
42
+ @dataclass
43
+ class Candidate:
44
+ baseline: Snapshot
45
+ state: Snapshot
46
+ changed: bool
47
+
48
+
49
+ class Draft:
50
+ def __init__(self, baseline: Snapshot):
51
+ self.baseline = baseline
52
+ self.state = baseline.model_copy(deep=True)
53
+ self.inspected = False
54
+ self.edits: list[dict] = []
55
+ self.attempts = 0
56
+
57
+ def inspect(self, arguments: dict):
58
+ if set(arguments) - {"id"}:
59
+ raise ValueError("Inspection accepts only an optional id")
60
+ self.inspected = True
61
+ if "id" in arguments:
62
+ return self.state.entries[arguments["id"]].model_dump()
63
+ return {
64
+ "revision": self.state.revision,
65
+ "entries": [
66
+ {"id": item.id, "kind": item.kind, "title": item.title}
67
+ for item in self.state.entries.values()
68
+ ],
69
+ "recent_changes": [item.model_dump() for item in self.state.history[-5:]],
70
+ }
71
+
72
+ def edit(self, arguments: dict):
73
+ if not self.inspected:
74
+ raise ValueError("Inspect the editable state before staging changes")
75
+ self.attempts += 1
76
+ if self.attempts > EDIT_LIMIT:
77
+ raise ValueError("The edit allowance for this review is exhausted")
78
+ next_state = self.state.model_copy(deep=True)
79
+ action = arguments.get("action")
80
+ if action == "delete":
81
+ if set(arguments) != {"action", "id"}:
82
+ raise ValueError("Delete requires action and id")
83
+ entry_id = arguments["id"]
84
+ if entry_id not in next_state.entries:
85
+ raise ValueError("Cannot remove absent guidance")
86
+ del next_state.entries[entry_id]
87
+ elif action in ("create", "update"):
88
+ if set(arguments) != {"action", "entry"}:
89
+ raise ValueError("Create and update require action and entry")
90
+ entry = Guidance.model_validate(arguments["entry"])
91
+ exists = entry.id in next_state.entries
92
+ if exists != (action == "update"):
93
+ raise ValueError("Create needs a new id; update needs an existing id")
94
+ next_state.entries[entry.id] = entry
95
+ entry_id = entry.id
96
+ else:
97
+ raise ValueError("Unsupported guidance operation")
98
+ self.state = Snapshot.model_validate(next_state.model_dump())
99
+ self.edits.append({"action": action, "id": entry_id})
100
+ return {"staged": len(self.edits), "id": entry_id}
101
+
102
+ def finish(self, value: dict) -> Candidate:
103
+ verdict = Verdict.model_validate(value)
104
+ if not self.inspected:
105
+ raise ValueError("Review ended without inspecting the harness")
106
+ changed = self.state.entries != self.baseline.entries
107
+ if changed:
108
+ self.state.revision += 1
109
+ self.state.history.append(
110
+ ChangeNote(id=uuid4().hex, **verdict.model_dump(), edits=self.edits)
111
+ )
112
+ return Candidate(self.baseline, self.state, changed)
113
+
114
+
115
+ def evidence_text(messages) -> str:
116
+ """Keep recent structured tool calls and outcomes, not just visible chat text."""
117
+ rows = []
118
+ length = 0
119
+ for message in reversed(messages):
120
+ row = json.dumps(message.model_dump(mode="json"), ensure_ascii=False)
121
+ if length + len(row) > INPUT_LIMIT:
122
+ if not rows:
123
+ rows.append(row[-INPUT_LIMIT:])
124
+ break
125
+ rows.append(row)
126
+ length += len(row) + 1
127
+ return "\n".join(reversed(rows))
128
+
129
+
130
+ async def review(provider, model, baseline, evidence, focus, signal, progress) -> Candidate:
131
+ draft = Draft(baseline)
132
+
133
+ async def unused(*args, **kwargs):
134
+ return ToolOutcome(content="Operations are dispatched by the review controller")
135
+
136
+ entry_schema = Guidance.model_json_schema()
137
+ specs = [
138
+ ToolSpec(
139
+ name="guidance_inspect",
140
+ label="Inspect guidance",
141
+ description="Read the saved guidance or one entry.",
142
+ parameters={
143
+ "type": "object",
144
+ "properties": {"id": {"type": "string"}},
145
+ "additionalProperties": False,
146
+ },
147
+ run=unused,
148
+ ),
149
+ ToolSpec(
150
+ name="guidance_edit",
151
+ label="Stage guidance",
152
+ description="Stage one create, update, or delete against editable guidance.",
153
+ parameters={
154
+ "type": "object",
155
+ "properties": {
156
+ "action": {"type": "string", "enum": ["create", "update", "delete"]},
157
+ "entry": entry_schema,
158
+ "id": {"type": "string"},
159
+ },
160
+ "required": ["action"],
161
+ "additionalProperties": False,
162
+ },
163
+ run=unused,
164
+ ),
165
+ ]
166
+ messages = [HumanEntry(content=json.dumps({"trajectory": evidence, "focus": focus}))]
167
+ operations = {"guidance_inspect": draft.inspect, "guidance_edit": draft.edit}
168
+ for step in range(STEP_LIMIT):
169
+ progress(f"reviewing step {step + 1}/{STEP_LIMIT}")
170
+ if signal.is_cancelled():
171
+ raise RuntimeError("Review cancelled")
172
+ reply = None
173
+ async for event in provider.stream_response(
174
+ model=model, system=POLICY, messages=messages, tools=specs, signal=signal
175
+ ):
176
+ if isinstance(event, StreamFaultEvent):
177
+ raise RuntimeError(event.error.error_message or "Refinement provider failed")
178
+ if isinstance(event, StreamCloseEvent):
179
+ reply = event.message
180
+ if reply is None:
181
+ raise ValueError("Refinement provider returned no final response")
182
+ messages.append(reply)
183
+ calls = [(call.name, call.arguments, call.id) for call in reply.tool_calls]
184
+ if not calls:
185
+ value = json.loads(reply.text)
186
+ if not isinstance(value, dict):
187
+ raise ValueError("Review response must be a JSON object")
188
+ if "tool" not in value:
189
+ return draft.finish(value)
190
+ if set(value) != {"tool", "arguments"} or not isinstance(value["arguments"], dict):
191
+ raise ValueError("Malformed review operation")
192
+ calls = [(value["tool"], value["arguments"], None)]
193
+ for name, arguments, call_id in calls:
194
+ if signal.is_cancelled():
195
+ raise RuntimeError("Review cancelled")
196
+ progress(f"running {name}")
197
+ try:
198
+ if name not in operations:
199
+ raise ValueError("This review cannot execute that operation")
200
+ result = operations[name](arguments)
201
+ content = json.dumps(result, ensure_ascii=False)
202
+ failed = False
203
+ except (ValueError, KeyError, TypeError) as error:
204
+ content, failed = str(error), True
205
+ if call_id is None:
206
+ messages.append(
207
+ HumanEntry(
208
+ content=json.dumps({"operation": name, "error": failed, "result": content})
209
+ )
210
+ )
211
+ else:
212
+ messages.append(
213
+ ToolOutcomeEntry(
214
+ tool_call_id=call_id,
215
+ tool_name=name,
216
+ content=[TextSegment(text=content)],
217
+ is_error=failed,
218
+ )
219
+ )
220
+ raise ValueError("Review reached its model-step limit without a final decision")