rlmness-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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")
|