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,131 @@
|
|
|
1
|
+
"""What is known about a saved change: that it was saved, reached the model, and what followed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
#: How many tasks either side of a change are compared.
|
|
11
|
+
WINDOW = 20
|
|
12
|
+
VERDICTS = ("better", "same", "worse")
|
|
13
|
+
#: A task with this many failed tool calls is worth a look for something to keep.
|
|
14
|
+
TROUBLE = 3
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EvidenceLog:
|
|
18
|
+
"""An append-only record per project. Nothing here changes what the agent does."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, path: Path | None):
|
|
21
|
+
self.path = path
|
|
22
|
+
self.rows: list[dict] = []
|
|
23
|
+
self.last_failed = 0
|
|
24
|
+
self._seen = {"transcript": 0, "events": 0}
|
|
25
|
+
if path is not None:
|
|
26
|
+
try:
|
|
27
|
+
for line in Path(path).read_text(encoding="utf-8").splitlines():
|
|
28
|
+
try:
|
|
29
|
+
self.rows.append(json.loads(line))
|
|
30
|
+
except ValueError:
|
|
31
|
+
continue
|
|
32
|
+
except OSError:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
def _add(self, row: dict) -> None:
|
|
36
|
+
row = {"time": time.time(), **row}
|
|
37
|
+
self.rows.append(row)
|
|
38
|
+
if self.path is None:
|
|
39
|
+
return
|
|
40
|
+
try:
|
|
41
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
with self.path.open("a", encoding="utf-8") as stream:
|
|
43
|
+
stream.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
44
|
+
except OSError:
|
|
45
|
+
pass # a record about the run must never stop the run
|
|
46
|
+
|
|
47
|
+
def saved(self, change: str, source: str, entries: list[str], summary: str = "") -> None:
|
|
48
|
+
self._add({"type": "saved", "change": change, "source": source,
|
|
49
|
+
"entries": entries, "summary": summary})
|
|
50
|
+
|
|
51
|
+
def rate(self, change: str, verdict: str) -> str:
|
|
52
|
+
if verdict not in VERDICTS:
|
|
53
|
+
return f"A verdict is one of {', '.join(VERDICTS)}."
|
|
54
|
+
if not any(r["type"] == "saved" and r["change"] == change for r in self.rows):
|
|
55
|
+
return f"No saved change {change}."
|
|
56
|
+
self._add({"type": "verdict", "change": change, "verdict": verdict})
|
|
57
|
+
return f"Change {change} rated {verdict}."
|
|
58
|
+
|
|
59
|
+
def task_finished(self, session) -> None:
|
|
60
|
+
"""One finished task: which saved entries the model could read, and how the tools went."""
|
|
61
|
+
calls, failed = self._tool_counts(session)
|
|
62
|
+
self.last_failed = failed
|
|
63
|
+
self._add({"type": "task", "calls": calls, "failed": failed,
|
|
64
|
+
"reached": sorted(self._reached(session))})
|
|
65
|
+
|
|
66
|
+
def _tool_counts(self, session) -> tuple[int, int]:
|
|
67
|
+
calls = failed = 0
|
|
68
|
+
transcript = list(session.transcript)
|
|
69
|
+
for message in transcript[self._seen["transcript"]:]:
|
|
70
|
+
if hasattr(message, "is_error") and hasattr(message, "tool_call_id"):
|
|
71
|
+
calls += 1
|
|
72
|
+
failed += bool(message.is_error)
|
|
73
|
+
self._seen["transcript"] = len(transcript)
|
|
74
|
+
events = list(getattr(getattr(session, "backend", None), "events", None) or [])
|
|
75
|
+
for event in events[self._seen["events"]:]:
|
|
76
|
+
if event.get("kind") == "host_result":
|
|
77
|
+
calls += 1
|
|
78
|
+
failed += str(event.get("result", "")).startswith("Error:")
|
|
79
|
+
elif event.get("kind") == "step" and event.get("step", 0) > 0 and event.get("code"):
|
|
80
|
+
calls += 1
|
|
81
|
+
failed += bool(event.get("error"))
|
|
82
|
+
self._seen["events"] = len(events)
|
|
83
|
+
return calls, failed
|
|
84
|
+
|
|
85
|
+
def _reached(self, session) -> set[str]:
|
|
86
|
+
"""Entries actually in what the model is sent, read back rather than assumed."""
|
|
87
|
+
names = {name for r in self.rows if r["type"] == "saved" for name in r["entries"]}
|
|
88
|
+
if not names:
|
|
89
|
+
return set()
|
|
90
|
+
backend = getattr(session, "backend", None)
|
|
91
|
+
sent = [getattr(session, "_system", "") or "", getattr(backend, "instructions", "") or ""]
|
|
92
|
+
sent += [getattr(m, "text", "") or "" for m in session.transcript]
|
|
93
|
+
text = "\n".join(sent)
|
|
94
|
+
return {
|
|
95
|
+
name for name in names
|
|
96
|
+
if re.search(rf"(?:\] |Workflow |prompt |subagent ){re.escape(name)}\b", text)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def report(self) -> str:
|
|
100
|
+
changes = [(n, r) for n, r in enumerate(self.rows) if r["type"] == "saved"]
|
|
101
|
+
if not changes:
|
|
102
|
+
return "No saved changes have been recorded for this project."
|
|
103
|
+
# By position: clocks can give two neighbouring rows the same time.
|
|
104
|
+
tasks = [(n, r) for n, r in enumerate(self.rows) if r["type"] == "task"]
|
|
105
|
+
lines = []
|
|
106
|
+
for position, change in changes[-10:]:
|
|
107
|
+
before = [t for n, t in tasks if n < position][-WINDOW:]
|
|
108
|
+
later = [t for n, t in tasks if n > position]
|
|
109
|
+
after = later[:WINDOW]
|
|
110
|
+
names = set(change["entries"])
|
|
111
|
+
reached = sum(1 for t in later if names & set(t["reached"]))
|
|
112
|
+
verdicts = [r["verdict"] for r in self.rows
|
|
113
|
+
if r["type"] == "verdict" and r["change"] == change["change"]]
|
|
114
|
+
lines += [
|
|
115
|
+
f"{change['change']} [{change['source']}] {', '.join(change['entries'])}",
|
|
116
|
+
" saved: " + time.strftime("%Y-%m-%d %H:%M", time.localtime(change["time"])),
|
|
117
|
+
f" reached the model: in {reached} of {len(later)} later tasks",
|
|
118
|
+
f" failed tool calls per task: {_rate(before)} before, {_rate(after)} after",
|
|
119
|
+
f" your verdict: {verdicts[-1] if verdicts else 'none yet'}",
|
|
120
|
+
]
|
|
121
|
+
lines.append(
|
|
122
|
+
"These counts are signals, not proof: tasks differ, and nothing here was a "
|
|
123
|
+
"controlled test. Record a judgement with /refine rate <change> better|same|worse."
|
|
124
|
+
)
|
|
125
|
+
return "\n".join(lines)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _rate(tasks: list[dict]) -> str:
|
|
129
|
+
if not tasks:
|
|
130
|
+
return "no data"
|
|
131
|
+
return f"{sum(t['failed'] for t in tasks) / len(tasks):.2f} over {len(tasks)} tasks"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Branch-local snapshots inherited along the saved conversation ancestry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from rlmagent.harness.session.store import deserialize_records
|
|
9
|
+
from rlmagent.refinement.store import HarnessStore, Snapshot
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ScopedStore(HarnessStore):
|
|
13
|
+
def __init__(self, path, session, inherited: Snapshot):
|
|
14
|
+
super().__init__(path)
|
|
15
|
+
self.session = session
|
|
16
|
+
self.initial = inherited.model_copy(deep=True)
|
|
17
|
+
self._document = {"base": self.initial.model_dump(), "anchors": {}}
|
|
18
|
+
|
|
19
|
+
def _data(self):
|
|
20
|
+
if self.path is None:
|
|
21
|
+
return self._document
|
|
22
|
+
try:
|
|
23
|
+
return json.loads(self.path.read_text(encoding="utf-8"))
|
|
24
|
+
except FileNotFoundError:
|
|
25
|
+
return self._document
|
|
26
|
+
|
|
27
|
+
def _save(self, data):
|
|
28
|
+
if self.path is None:
|
|
29
|
+
self._document = data
|
|
30
|
+
return
|
|
31
|
+
temporary = self.path.with_suffix(f".{uuid4().hex}.tmp")
|
|
32
|
+
try:
|
|
33
|
+
temporary.write_text(json.dumps(data), encoding="utf-8")
|
|
34
|
+
temporary.replace(self.path)
|
|
35
|
+
finally:
|
|
36
|
+
temporary.unlink(missing_ok=True)
|
|
37
|
+
|
|
38
|
+
def _lineage(self):
|
|
39
|
+
tip = self.session._tip_id
|
|
40
|
+
vault = self.session._vault
|
|
41
|
+
parents = {}
|
|
42
|
+
if vault is not None and vault.path.exists():
|
|
43
|
+
records = deserialize_records(vault.path.read_text(encoding="utf-8").split("\n"))
|
|
44
|
+
parents = {record.id: record.parent_id for record in records}
|
|
45
|
+
seen = set()
|
|
46
|
+
while tip is not None and tip not in seen:
|
|
47
|
+
seen.add(tip)
|
|
48
|
+
yield tip
|
|
49
|
+
tip = parents.get(tip)
|
|
50
|
+
yield "base"
|
|
51
|
+
|
|
52
|
+
def _read(self):
|
|
53
|
+
data = self._data()
|
|
54
|
+
for anchor in self._lineage():
|
|
55
|
+
versions = data["anchors"].get(anchor, [])
|
|
56
|
+
if versions:
|
|
57
|
+
return Snapshot.model_validate(versions[-1])
|
|
58
|
+
return Snapshot.model_validate(data["base"])
|
|
59
|
+
|
|
60
|
+
def initialize(self):
|
|
61
|
+
with self.transaction():
|
|
62
|
+
self._save(self._data())
|
|
63
|
+
|
|
64
|
+
def commit(self, baseline: Snapshot, candidate: Snapshot):
|
|
65
|
+
candidate = Snapshot.model_validate(candidate.model_dump())
|
|
66
|
+
with self.transaction():
|
|
67
|
+
if self._read() != baseline:
|
|
68
|
+
raise ValueError("Branch guidance changed during review; start a fresh review")
|
|
69
|
+
if candidate.revision != baseline.revision + 1:
|
|
70
|
+
raise ValueError("Candidate must advance the harness revision once")
|
|
71
|
+
data = self._data()
|
|
72
|
+
anchor = self.session._tip_id or "base"
|
|
73
|
+
history = data["anchors"].setdefault(anchor, [])
|
|
74
|
+
if not history:
|
|
75
|
+
history.append(baseline.model_dump())
|
|
76
|
+
history.append(candidate.model_dump())
|
|
77
|
+
self._save(data)
|
|
78
|
+
return candidate.model_copy(deep=True)
|
|
79
|
+
|
|
80
|
+
def version(self, revision: int):
|
|
81
|
+
with self.transaction():
|
|
82
|
+
data = self._data()
|
|
83
|
+
for anchor in self._lineage():
|
|
84
|
+
for snapshot in reversed(data["anchors"].get(anchor, [])):
|
|
85
|
+
if snapshot["revision"] == revision:
|
|
86
|
+
return Snapshot.model_validate(snapshot)
|
|
87
|
+
baseline = Snapshot.model_validate(data["base"])
|
|
88
|
+
if baseline.revision == revision:
|
|
89
|
+
return baseline
|
|
90
|
+
raise ValueError("No saved snapshot for this revision in the current branch")
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Validated harness snapshots and compare-and-swap persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
16
|
+
|
|
17
|
+
MAX_CONTENT = 32 * 1024
|
|
18
|
+
MAX_ENTRIES = 128
|
|
19
|
+
_LOCKS: dict[str, threading.RLock] = {}
|
|
20
|
+
_LOCK_GUARD = threading.Lock()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Record(BaseModel):
|
|
24
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Guidance(Record):
|
|
28
|
+
id: str = Field(pattern=r"^[a-z][a-z0-9-]{0,63}$")
|
|
29
|
+
kind: Literal["prompt", "skill", "subagent"]
|
|
30
|
+
title: str = Field(min_length=1, max_length=160)
|
|
31
|
+
content: str = Field(min_length=1)
|
|
32
|
+
|
|
33
|
+
@model_validator(mode="after")
|
|
34
|
+
def check_content(self):
|
|
35
|
+
if not self.title.strip() or not self.content.strip():
|
|
36
|
+
raise ValueError("Guidance needs a title and nonblank content")
|
|
37
|
+
if len(self.content.encode("utf-8")) > MAX_CONTENT:
|
|
38
|
+
raise ValueError("Guidance exceeds the byte limit")
|
|
39
|
+
if self.kind == "skill":
|
|
40
|
+
skill_parts(self.id, self.content)
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def skill_parts(name: str, content: str) -> tuple[str, str]:
|
|
45
|
+
match = re.fullmatch(r"---\r?\n(.*?)\r?\n---\r?\n(.*)", content, re.S)
|
|
46
|
+
if match is None:
|
|
47
|
+
raise ValueError("A workflow must begin with YAML frontmatter")
|
|
48
|
+
# Reject duplicate metadata keys rather than silently choosing the last value.
|
|
49
|
+
try:
|
|
50
|
+
node = yaml.compose(match[1], Loader=yaml.SafeLoader)
|
|
51
|
+
meta = yaml.safe_load(match[1])
|
|
52
|
+
except yaml.YAMLError as error:
|
|
53
|
+
raise ValueError("Invalid workflow metadata") from error
|
|
54
|
+
if not isinstance(node, yaml.MappingNode):
|
|
55
|
+
raise ValueError("Workflow metadata must be a mapping")
|
|
56
|
+
keys = [key.value for key, _ in node.value]
|
|
57
|
+
if sorted(keys) != ["description", "name"]:
|
|
58
|
+
raise ValueError("Workflow metadata needs exactly name and description")
|
|
59
|
+
if meta["name"] != name or not isinstance(meta["description"], str):
|
|
60
|
+
raise ValueError("Workflow name must match its id; description must be text")
|
|
61
|
+
if not meta["description"].strip() or not match[2].strip():
|
|
62
|
+
raise ValueError("Workflow description and procedure must not be blank")
|
|
63
|
+
return meta["description"], match[2]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ChangeNote(Record):
|
|
67
|
+
id: str
|
|
68
|
+
summary: str = Field(min_length=1, max_length=2000)
|
|
69
|
+
evidence: str = Field(min_length=1, max_length=8000)
|
|
70
|
+
expected: str = Field(min_length=1, max_length=2000)
|
|
71
|
+
edits: list[dict]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Snapshot(Record):
|
|
75
|
+
format: Literal["rlmagent.harness/1"] = "rlmagent.harness/1"
|
|
76
|
+
revision: int = Field(default=0, ge=0)
|
|
77
|
+
entries: dict[str, Guidance] = Field(default_factory=dict)
|
|
78
|
+
history: list[ChangeNote] = Field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
@model_validator(mode="after")
|
|
81
|
+
def check_ids(self):
|
|
82
|
+
if len(self.entries) > MAX_ENTRIES:
|
|
83
|
+
raise ValueError("Too many guidance entries")
|
|
84
|
+
if any(key != entry.id for key, entry in self.entries.items()):
|
|
85
|
+
raise ValueError("Stored guidance id does not match its key")
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@contextmanager
|
|
90
|
+
def _lock(path: Path):
|
|
91
|
+
key = str(path.resolve())
|
|
92
|
+
with _LOCK_GUARD:
|
|
93
|
+
mutex = _LOCKS.setdefault(key, threading.RLock())
|
|
94
|
+
with mutex:
|
|
95
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
with path.open("a+b") as handle:
|
|
97
|
+
handle.seek(0, 2)
|
|
98
|
+
if handle.tell() == 0:
|
|
99
|
+
handle.write(b"0")
|
|
100
|
+
handle.flush()
|
|
101
|
+
handle.seek(0)
|
|
102
|
+
if os.name == "nt":
|
|
103
|
+
import msvcrt
|
|
104
|
+
|
|
105
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
|
106
|
+
else:
|
|
107
|
+
import fcntl
|
|
108
|
+
|
|
109
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
110
|
+
try:
|
|
111
|
+
yield
|
|
112
|
+
finally:
|
|
113
|
+
handle.seek(0)
|
|
114
|
+
if os.name == "nt":
|
|
115
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
116
|
+
else:
|
|
117
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class HarnessStore:
|
|
121
|
+
def __init__(self, path: Path | None):
|
|
122
|
+
self.path = path
|
|
123
|
+
self._memory = Snapshot()
|
|
124
|
+
self._mutex = threading.RLock()
|
|
125
|
+
self._versions: dict[int, Snapshot] = {}
|
|
126
|
+
|
|
127
|
+
def _read(self) -> Snapshot:
|
|
128
|
+
if self.path is None:
|
|
129
|
+
return self._memory.model_copy(deep=True)
|
|
130
|
+
try:
|
|
131
|
+
raw = self.path.read_text(encoding="utf-8")
|
|
132
|
+
except FileNotFoundError:
|
|
133
|
+
return Snapshot()
|
|
134
|
+
return Snapshot.model_validate(json.loads(raw))
|
|
135
|
+
|
|
136
|
+
@contextmanager
|
|
137
|
+
def transaction(self):
|
|
138
|
+
with self._mutex:
|
|
139
|
+
if self.path is None:
|
|
140
|
+
yield
|
|
141
|
+
else:
|
|
142
|
+
with _lock(self.path.with_suffix(".lock")):
|
|
143
|
+
yield
|
|
144
|
+
|
|
145
|
+
def read(self) -> Snapshot:
|
|
146
|
+
with self.transaction():
|
|
147
|
+
return self._read()
|
|
148
|
+
|
|
149
|
+
def commit(self, baseline: Snapshot, candidate: Snapshot) -> Snapshot:
|
|
150
|
+
candidate = Snapshot.model_validate(candidate.model_dump())
|
|
151
|
+
with self.transaction():
|
|
152
|
+
if self._read() != baseline:
|
|
153
|
+
raise ValueError("Harness changed during refinement; start a fresh review")
|
|
154
|
+
if candidate.revision != baseline.revision + 1:
|
|
155
|
+
raise ValueError("Candidate must advance the harness revision once")
|
|
156
|
+
if self.path is None:
|
|
157
|
+
self._versions[baseline.revision] = baseline.model_copy(deep=True)
|
|
158
|
+
self._memory = candidate.model_copy(deep=True)
|
|
159
|
+
else:
|
|
160
|
+
versions = self.path.parent / "snapshots"
|
|
161
|
+
versions.mkdir(exist_ok=True)
|
|
162
|
+
saved = versions / f"{baseline.revision}.json"
|
|
163
|
+
if not saved.exists():
|
|
164
|
+
with saved.open("x", encoding="utf-8") as output:
|
|
165
|
+
output.write(baseline.model_dump_json(indent=2))
|
|
166
|
+
descriptor, name = tempfile.mkstemp(prefix=".harness-", dir=self.path.parent)
|
|
167
|
+
temporary = Path(name)
|
|
168
|
+
try:
|
|
169
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as output:
|
|
170
|
+
output.write(candidate.model_dump_json(indent=2) + "\n")
|
|
171
|
+
output.flush()
|
|
172
|
+
os.fsync(output.fileno())
|
|
173
|
+
os.replace(temporary, self.path)
|
|
174
|
+
finally:
|
|
175
|
+
temporary.unlink(missing_ok=True)
|
|
176
|
+
return candidate.model_copy(deep=True)
|
|
177
|
+
|
|
178
|
+
def version(self, revision: int) -> Snapshot:
|
|
179
|
+
if revision < 0:
|
|
180
|
+
raise ValueError("revision must be nonnegative")
|
|
181
|
+
current = self.read()
|
|
182
|
+
if current.revision == revision:
|
|
183
|
+
return current
|
|
184
|
+
if self.path is None:
|
|
185
|
+
if revision not in self._versions:
|
|
186
|
+
raise ValueError("no saved snapshot for this revision")
|
|
187
|
+
return self._versions[revision].model_copy(deep=True)
|
|
188
|
+
try:
|
|
189
|
+
return Snapshot.model_validate_json(
|
|
190
|
+
(self.path.parent / "snapshots" / f"{revision}.json").read_text(encoding="utf-8")
|
|
191
|
+
)
|
|
192
|
+
except FileNotFoundError:
|
|
193
|
+
raise ValueError("no saved snapshot for this revision") from None
|
rlmagent/refresh.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Reload diagnostics: structured JSONL logging for hot-reload operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import traceback
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
# ── constants ──────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
_LOG_FILENAME = "reload_diag.jsonl"
|
|
17
|
+
_MAX_ERROR_LENGTH = 300
|
|
18
|
+
_MAX_TRACEBACK_LINES = 20
|
|
19
|
+
|
|
20
|
+
# Patterns that look like secrets — replaced with [REDACTED] before logging.
|
|
21
|
+
_SECRET_RE = re.compile(
|
|
22
|
+
r"(sk-[A-Za-z0-9]{8,})" # Anthropic / OpenAI API keys
|
|
23
|
+
r"|(key-[A-Za-z0-9]{8,})" # generic key- prefixed tokens
|
|
24
|
+
r"|(Bearer\s+\S+)" # Authorization headers
|
|
25
|
+
r"|(token[=:]\s*\S+)" # token= or token: values
|
|
26
|
+
r"|(password[=:]\s*\S+)" # password= or password: values
|
|
27
|
+
r"|(secret[=:]\s*\S+)", # secret= or secret: values
|
|
28
|
+
re.IGNORECASE,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── scrubbing ──────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _scrub(text: str) -> str:
|
|
36
|
+
"""Replace substrings that look like secrets with ``[REDACTED]``."""
|
|
37
|
+
return _SECRET_RE.sub("[REDACTED]", text)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _scrub_error(message: str) -> str:
|
|
41
|
+
"""Scrub and truncate an error message for safe logging."""
|
|
42
|
+
scrubbed = _scrub(message)
|
|
43
|
+
if len(scrubbed) > _MAX_ERROR_LENGTH:
|
|
44
|
+
scrubbed = scrubbed[:_MAX_ERROR_LENGTH] + "…"
|
|
45
|
+
return scrubbed
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _scrub_traceback(exc: BaseException) -> list[str]:
|
|
49
|
+
"""Format an exception traceback and scrub each line of secrets."""
|
|
50
|
+
raw = traceback.format_exception(type(exc), exc, exc.__traceback__)
|
|
51
|
+
flat: list[str] = []
|
|
52
|
+
for chunk in raw:
|
|
53
|
+
flat.extend(chunk.rstrip().splitlines())
|
|
54
|
+
|
|
55
|
+
scrubbed = [_scrub(line) for line in flat]
|
|
56
|
+
if len(scrubbed) > _MAX_TRACEBACK_LINES:
|
|
57
|
+
overflow = len(flat) - _MAX_TRACEBACK_LINES
|
|
58
|
+
scrubbed = scrubbed[:_MAX_TRACEBACK_LINES] + [f"… ({overflow} more lines)"]
|
|
59
|
+
return scrubbed
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── timestamps & run IDs ──────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _utc_now() -> datetime:
|
|
66
|
+
"""Return the current UTC time (timezone-aware)."""
|
|
67
|
+
return datetime.now(UTC)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _iso_utc(dt: datetime) -> str:
|
|
71
|
+
"""Format *dt* as a compact ISO-8601 UTC string."""
|
|
72
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def new_run_id() -> str:
|
|
76
|
+
"""Generate a unique 12-character hex identifier for a reload run."""
|
|
77
|
+
return uuid.uuid4().hex[:12]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── ReloadDiagnosticContext ───────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class ReloadDiagnosticContext:
|
|
85
|
+
"""Immutable metadata stamp for one reload operation."""
|
|
86
|
+
|
|
87
|
+
run_id: str
|
|
88
|
+
started_at: str
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def create() -> ReloadDiagnosticContext:
|
|
92
|
+
"""Build a fresh context with a new run ID and current UTC time."""
|
|
93
|
+
return ReloadDiagnosticContext(
|
|
94
|
+
run_id=new_run_id(),
|
|
95
|
+
started_at=_iso_utc(_utc_now()),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── log entry helpers ─────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_base_entry(ctx: ReloadDiagnosticContext) -> dict[str, Any]:
|
|
103
|
+
"""Return the fields common to every diagnostic log line."""
|
|
104
|
+
return {
|
|
105
|
+
"run_id": ctx.run_id,
|
|
106
|
+
"ts": _iso_utc(_utc_now()),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_failure_entry(
|
|
111
|
+
ctx: ReloadDiagnosticContext,
|
|
112
|
+
category: str,
|
|
113
|
+
name: str,
|
|
114
|
+
error: str,
|
|
115
|
+
*,
|
|
116
|
+
path: str = "",
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
"""Build a log entry for an expected reload failure."""
|
|
119
|
+
entry = build_base_entry(ctx)
|
|
120
|
+
entry.update(level="failure", category=category, name=name,
|
|
121
|
+
error=_scrub_error(error))
|
|
122
|
+
if path:
|
|
123
|
+
entry["path"] = path
|
|
124
|
+
return entry
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build_exception_entry(
|
|
128
|
+
ctx: ReloadDiagnosticContext,
|
|
129
|
+
category: str,
|
|
130
|
+
name: str,
|
|
131
|
+
exc: BaseException,
|
|
132
|
+
*,
|
|
133
|
+
path: str = "",
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
"""Build a log entry for an unexpected exception."""
|
|
136
|
+
entry = build_base_entry(ctx)
|
|
137
|
+
entry.update(level="error", category=category, name=name,
|
|
138
|
+
error=_scrub_error(str(exc)),
|
|
139
|
+
traceback=_scrub_traceback(exc))
|
|
140
|
+
if path:
|
|
141
|
+
entry["path"] = path
|
|
142
|
+
return entry
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ── ReloadDiagnosticLogger ────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ReloadDiagnosticLogger:
|
|
149
|
+
"""Appends structured JSONL diagnostic entries for reload operations."""
|
|
150
|
+
|
|
151
|
+
def __init__(self, log_dir: Path, *, filename: str = _LOG_FILENAME) -> None:
|
|
152
|
+
self._log_dir = log_dir
|
|
153
|
+
self._filename = filename
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def log_path(self) -> Path:
|
|
157
|
+
"""Full path to the diagnostic log file."""
|
|
158
|
+
return self._log_dir / self._filename
|
|
159
|
+
|
|
160
|
+
# ── writing ───────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
def _append(self, entry: dict[str, Any]) -> None:
|
|
163
|
+
"""Serialize *entry* as compact JSON and append it as one line."""
|
|
164
|
+
self._log_dir.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
with self.log_path.open("a", encoding="utf-8") as fh:
|
|
166
|
+
fh.write(json.dumps(entry, separators=(",", ":")) + "\n")
|
|
167
|
+
|
|
168
|
+
# ── public API ────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
def log_failure(
|
|
171
|
+
self,
|
|
172
|
+
ctx: ReloadDiagnosticContext,
|
|
173
|
+
category: str,
|
|
174
|
+
name: str,
|
|
175
|
+
error: str,
|
|
176
|
+
*,
|
|
177
|
+
path: str = "",
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Log an expected reload failure (bad file, missing field, etc.)."""
|
|
180
|
+
self._append(build_failure_entry(ctx, category, name, error, path=path))
|
|
181
|
+
|
|
182
|
+
def log_exception(
|
|
183
|
+
self,
|
|
184
|
+
ctx: ReloadDiagnosticContext,
|
|
185
|
+
category: str,
|
|
186
|
+
name: str,
|
|
187
|
+
exc: BaseException,
|
|
188
|
+
*,
|
|
189
|
+
path: str = "",
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Log an unexpected exception with a scrubbed traceback."""
|
|
192
|
+
self._append(build_exception_entry(ctx, category, name, exc, path=path))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
__all__ = [
|
|
196
|
+
"ReloadDiagnosticContext",
|
|
197
|
+
"ReloadDiagnosticLogger",
|
|
198
|
+
"build_base_entry",
|
|
199
|
+
"build_exception_entry",
|
|
200
|
+
"build_failure_entry",
|
|
201
|
+
"new_run_id",
|
|
202
|
+
]
|
rlmagent/routing.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Inference-route pinning for the Hugging Face router."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
ROUTED_PROVIDER = "huggingface"
|
|
9
|
+
|
|
10
|
+
RouteMode = Literal["automatic", "fixed"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class RoutePin:
|
|
15
|
+
"""Which backend requests are sent to, and whether it may change."""
|
|
16
|
+
|
|
17
|
+
mode: RouteMode = "automatic"
|
|
18
|
+
backend: str | None = None
|
|
19
|
+
|
|
20
|
+
def model_for(self, model: str) -> str:
|
|
21
|
+
"""The model id to send: suffixed with the backend when pinned."""
|
|
22
|
+
return f"{model}:{self.backend}" if self.backend else model
|
|
23
|
+
|
|
24
|
+
def describe(self) -> str:
|
|
25
|
+
if self.mode == "fixed" and self.backend:
|
|
26
|
+
return f"{self.backend} (fixed)"
|
|
27
|
+
if self.backend:
|
|
28
|
+
return f"automatic (currently {self.backend})"
|
|
29
|
+
return "automatic"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def pin_from_storage(backend: str | None, mode: str | None) -> RoutePin:
|
|
33
|
+
"""Rebuild a pin from saved metadata."""
|
|
34
|
+
if mode in ("automatic", "fixed"):
|
|
35
|
+
return RoutePin(mode, backend) # type: ignore[arg-type]
|
|
36
|
+
return RoutePin("fixed", backend) if backend else RoutePin()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_route_failure(status: int | None) -> bool:
|
|
40
|
+
"""Statuses worth abandoning a learned backend for (after its own retries)."""
|
|
41
|
+
if status is None:
|
|
42
|
+
return False
|
|
43
|
+
return status in (408, 409, 425, 429) or status >= 500
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"ROUTED_PROVIDER",
|
|
48
|
+
"RouteMode",
|
|
49
|
+
"RoutePin",
|
|
50
|
+
"is_route_failure",
|
|
51
|
+
"pin_from_storage",
|
|
52
|
+
]
|