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,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
+ ]