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,189 @@
1
+ """Typed streaming signals emitted by model adapters and the agent wire protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ from rlmagent.harness.contracts.transcript import CallBlock, ModelEntry, StrictModel
10
+ from rlmagent.harness.contracts.values import JValue
11
+
12
+
13
+ class StreamOpenEvent(StrictModel):
14
+ type: Literal["start"] = "start"
15
+ partial: ModelEntry
16
+
17
+
18
+ class ContentOpenEvent(StrictModel):
19
+ type: Literal["text_start"] = "text_start"
20
+ content_index: int
21
+ partial: ModelEntry
22
+
23
+
24
+ class ContentChunkEvent(StrictModel):
25
+ type: Literal["text_delta"] = "text_delta"
26
+ content_index: int
27
+ delta: str
28
+ partial: ModelEntry
29
+
30
+
31
+ class ContentCloseEvent(StrictModel):
32
+ type: Literal["text_end"] = "text_end"
33
+ content_index: int
34
+ content: str
35
+ partial: ModelEntry
36
+
37
+
38
+ class ReasoningOpenEvent(StrictModel):
39
+ type: Literal["thinking_start"] = "thinking_start"
40
+ content_index: int
41
+ partial: ModelEntry
42
+
43
+
44
+ class ReasoningChunkEvent(StrictModel):
45
+ type: Literal["thinking_delta"] = "thinking_delta"
46
+ content_index: int
47
+ delta: str
48
+ partial: ModelEntry
49
+
50
+
51
+ class ReasoningCloseEvent(StrictModel):
52
+ type: Literal["thinking_end"] = "thinking_end"
53
+ content_index: int
54
+ content: str
55
+ partial: ModelEntry
56
+
57
+
58
+ class CallOpenEvent(StrictModel):
59
+ type: Literal["toolcall_start"] = "toolcall_start"
60
+ content_index: int
61
+ partial: ModelEntry
62
+
63
+
64
+ class CallChunkEvent(StrictModel):
65
+ type: Literal["toolcall_delta"] = "toolcall_delta"
66
+ content_index: int
67
+ delta: str
68
+ partial: ModelEntry
69
+
70
+
71
+ class CallCloseEvent(StrictModel):
72
+ type: Literal["toolcall_end"] = "toolcall_end"
73
+ content_index: int
74
+ tool_call: CallBlock
75
+ partial: ModelEntry
76
+
77
+
78
+ CompletionCause = Literal["stop", "length", "toolUse"]
79
+ FaultCause = Literal["aborted", "error"]
80
+
81
+
82
+ class StreamCloseEvent(StrictModel):
83
+ type: Literal["done"] = "done"
84
+ reason: CompletionCause
85
+ message: ModelEntry
86
+
87
+
88
+ class StreamFaultEvent(StrictModel):
89
+ type: Literal["error"] = "error"
90
+ reason: FaultCause
91
+ error: ModelEntry
92
+
93
+
94
+ type WireEvent = Annotated[
95
+ StreamOpenEvent
96
+ | StreamCloseEvent
97
+ | StreamFaultEvent
98
+ | ContentOpenEvent
99
+ | ContentChunkEvent
100
+ | ContentCloseEvent
101
+ | ReasoningOpenEvent
102
+ | ReasoningChunkEvent
103
+ | ReasoningCloseEvent
104
+ | CallOpenEvent
105
+ | CallChunkEvent
106
+ | CallCloseEvent
107
+ | SourceRetryEvent,
108
+ Field(discriminator="type"),
109
+ ]
110
+
111
+
112
+ class SourceStartEvent(BaseModel):
113
+ """Signals that a new model completion is being streamed."""
114
+
115
+ model_config = ConfigDict(extra="forbid")
116
+
117
+ type: Literal["response_start"] = "response_start"
118
+ model: str
119
+
120
+
121
+ class SourceRetryEvent(BaseModel):
122
+ """Adapter is backing off and reattempting after a transient failure."""
123
+
124
+ model_config = ConfigDict(extra="forbid")
125
+
126
+ type: Literal["retry"] = "retry"
127
+ attempt: int
128
+ max_attempts: int
129
+ delay_seconds: float
130
+ message: str
131
+ data: dict[str, JValue] | None = None
132
+
133
+
134
+ class SourceTextDelta(BaseModel):
135
+ """An incremental chunk of plain text from the model."""
136
+
137
+ model_config = ConfigDict(extra="forbid")
138
+
139
+ type: Literal["text_delta"] = "text_delta"
140
+ delta: str
141
+
142
+
143
+ class SourceThinkingDelta(BaseModel):
144
+ """An incremental chunk of reasoning output from the model."""
145
+
146
+ model_config = ConfigDict(extra="forbid")
147
+
148
+ type: Literal["thinking_delta"] = "thinking_delta"
149
+ delta: str
150
+
151
+
152
+ class SourceCallEvent(BaseModel):
153
+ """A fully resolved tool invocation emitted by the model."""
154
+
155
+ model_config = ConfigDict(extra="forbid")
156
+
157
+ type: Literal["tool_call"] = "tool_call"
158
+ tool_call: CallBlock
159
+
160
+
161
+ class SourceEndEvent(BaseModel):
162
+ """The model response has been fully received and assembled."""
163
+
164
+ model_config = ConfigDict(extra="forbid")
165
+
166
+ type: Literal["response_end"] = "response_end"
167
+ message: ModelEntry
168
+ finish_reason: str | None = None
169
+
170
+
171
+ class SourceErrorEvent(BaseModel):
172
+ """An adapter-level fault surfaced to the harness for display or recovery."""
173
+
174
+ model_config = ConfigDict(extra="forbid")
175
+
176
+ type: Literal["error"] = "error"
177
+ message: str
178
+ data: dict[str, JValue] | None = None
179
+
180
+
181
+ type SourceEvent = (
182
+ SourceStartEvent
183
+ | SourceRetryEvent
184
+ | SourceTextDelta
185
+ | SourceThinkingDelta
186
+ | SourceCallEvent
187
+ | SourceEndEvent
188
+ | SourceErrorEvent
189
+ )
File without changes
@@ -0,0 +1,5 @@
1
+ """Append-only, event-sourced session persistence."""
2
+
3
+ from rlmagent.harness.session.index import SessionCatalog, SessionMeta
4
+
5
+ __all__ = ["SessionCatalog", "SessionMeta"]
@@ -0,0 +1,218 @@
1
+ """Lightweight session metadata index backed by a JSONL file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, replace
7
+ from pathlib import Path
8
+ from time import time
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+ from rlmagent.harness.session.store import jsonl_lines
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Serialization model
16
+ # ---------------------------------------------------------------------------
17
+
18
+
19
+ class SessionMetaWire(BaseModel):
20
+ """Pydantic model for (de)serializing a single index entry."""
21
+
22
+ model_config = ConfigDict(extra="forbid")
23
+
24
+ session_id: str
25
+ vault_path: str
26
+ cwd: str
27
+ model: str
28
+ provider: str
29
+ title: str | None = None
30
+ created_at: float = Field(default_factory=time)
31
+ updated_at: float = Field(default_factory=time)
32
+ inference_provider: str | None = None
33
+ inference_provider_mode: str | None = None
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Runtime representation
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class SessionMeta:
43
+ """Immutable snapshot of session metadata for runtime use."""
44
+
45
+ session_id: str
46
+ vault_path: str
47
+ cwd: str
48
+ model: str
49
+ provider: str
50
+ title: str | None
51
+ created_at: float
52
+ updated_at: float
53
+ # Backend pin for routed providers, and whether it was learned or chosen.
54
+ inference_provider: str | None = None
55
+ inference_provider_mode: str | None = None
56
+
57
+ def to_wire(self) -> SessionMetaWire:
58
+ """Convert to a serializable wire model."""
59
+ return SessionMetaWire(
60
+ session_id=self.session_id,
61
+ vault_path=self.vault_path,
62
+ cwd=self.cwd,
63
+ model=self.model,
64
+ provider=self.provider,
65
+ title=self.title,
66
+ created_at=self.created_at,
67
+ updated_at=self.updated_at,
68
+ inference_provider=self.inference_provider,
69
+ inference_provider_mode=self.inference_provider_mode,
70
+ )
71
+
72
+
73
+ def _wire_to_meta(wire: SessionMetaWire) -> SessionMeta:
74
+ """Convert a wire model to the frozen runtime representation."""
75
+ return SessionMeta(
76
+ session_id=wire.session_id,
77
+ vault_path=wire.vault_path,
78
+ cwd=wire.cwd,
79
+ model=wire.model,
80
+ provider=wire.provider,
81
+ title=wire.title,
82
+ created_at=wire.created_at,
83
+ updated_at=wire.updated_at,
84
+ inference_provider=wire.inference_provider,
85
+ inference_provider_mode=wire.inference_provider_mode,
86
+ )
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Catalog
91
+ # ---------------------------------------------------------------------------
92
+
93
+ _INDEX_FILENAME = "index.jsonl"
94
+
95
+
96
+ class SessionCatalog:
97
+ """Persistent index of session metadata stored as JSONL."""
98
+
99
+ def __init__(self, sessions_dir: str | Path) -> None:
100
+ self._dir = Path(sessions_dir)
101
+ self._path = self._dir / _INDEX_FILENAME
102
+
103
+ @property
104
+ def path(self) -> Path:
105
+ """Absolute path to the backing ``index.jsonl``."""
106
+ return self._path
107
+
108
+ # ── factory helpers ───────────────────────────────────────────────
109
+
110
+ @staticmethod
111
+ def prepare(
112
+ *,
113
+ session_id: str,
114
+ vault_path: str | Path,
115
+ cwd: str,
116
+ model: str,
117
+ provider: str,
118
+ title: str | None = None,
119
+ ) -> SessionMeta:
120
+ """Build a ``SessionMeta`` without writing it to any index."""
121
+ now = time()
122
+ return SessionMeta(
123
+ session_id=session_id,
124
+ vault_path=str(vault_path),
125
+ cwd=cwd,
126
+ model=model,
127
+ provider=provider,
128
+ title=title,
129
+ created_at=now,
130
+ updated_at=now,
131
+ )
132
+
133
+ # ── writes ────────────────────────────────────────────────────────
134
+
135
+ def upsert(self, meta: SessionMeta) -> None:
136
+ """Insert or replace the entry for ``meta.session_id``."""
137
+ self._dir.mkdir(parents=True, exist_ok=True)
138
+ entries = self._load_deduped()
139
+ current = entries.get(meta.session_id)
140
+ if current is not None and meta.updated_at < current.updated_at:
141
+ return
142
+ entries[meta.session_id] = meta
143
+
144
+ payload = "".join(
145
+ entry.to_wire().model_dump_json(exclude_none=True) + "\n"
146
+ for entry in sorted(entries.values(), key=lambda m: m.updated_at)
147
+ )
148
+ staged = self._path.with_name(f"{self._path.name}.tmp")
149
+ staged.write_text(payload, encoding="utf-8")
150
+ staged.replace(self._path)
151
+
152
+ def remove(self, session_ids: set[str]) -> None:
153
+ """Forget these sessions."""
154
+ entries = self._load_deduped()
155
+ payload = "".join(
156
+ entry.to_wire().model_dump_json(exclude_none=True) + "\n"
157
+ for entry in sorted(entries.values(), key=lambda m: m.updated_at)
158
+ if entry.session_id not in session_ids
159
+ )
160
+ staged = self._path.with_name(f"{self._path.name}.tmp")
161
+ staged.write_text(payload, encoding="utf-8")
162
+ staged.replace(self._path)
163
+
164
+ def touch(self, session_id: str) -> SessionMeta | None:
165
+ """Update the ``updated_at`` timestamp for *session_id*."""
166
+ existing = self.get(session_id)
167
+ if existing is None:
168
+ return None
169
+ refreshed = replace(existing, updated_at=time())
170
+ self.upsert(refreshed)
171
+ return refreshed
172
+
173
+ # ── reads ─────────────────────────────────────────────────────────
174
+
175
+ def list_all(self) -> list[SessionMeta]:
176
+ """Return all sessions ordered by ``updated_at`` descending."""
177
+ entries = self._load_deduped()
178
+ return sorted(entries.values(), key=lambda m: m.updated_at, reverse=True)
179
+
180
+ def get(self, session_id: str) -> SessionMeta | None:
181
+ """Look up a single session by its id, or ``None`` if absent."""
182
+ entries = self._load_deduped()
183
+ return entries.get(session_id)
184
+
185
+ def latest_for_cwd(self, cwd: str) -> SessionMeta | None:
186
+ """Return the most recently used session for *cwd*, or ``None``."""
187
+ candidates = [
188
+ m for m in self._load_deduped().values() if m.cwd == cwd
189
+ ]
190
+ if not candidates:
191
+ return None
192
+ return max(candidates, key=lambda m: m.updated_at)
193
+
194
+ # ── internals ─────────────────────────────────────────────────────
195
+
196
+ def _load_deduped(self) -> dict[str, SessionMeta]:
197
+ """Read every line, parse, and deduplicate by session_id."""
198
+ if not self._path.exists():
199
+ return {}
200
+
201
+ result: dict[str, SessionMeta] = {}
202
+ for line in jsonl_lines(self._path.read_text(encoding="utf-8")):
203
+ stripped = line.strip()
204
+ if not stripped:
205
+ continue
206
+ try:
207
+ raw = json.loads(stripped)
208
+ wire = SessionMetaWire.model_validate(raw)
209
+ meta = _wire_to_meta(wire)
210
+ except Exception: # noqa: BLE001 - one bad line must not hide the rest
211
+ continue
212
+ existing = result.get(meta.session_id)
213
+ # The file is append-ordered, so on an equal timestamp the later
214
+ # line is the newer write. Coarse clocks (Windows ~15ms) tie often
215
+ # enough that a strict `>` silently discards fresh updates.
216
+ if existing is None or meta.updated_at >= existing.updated_at:
217
+ result[meta.session_id] = meta
218
+ return result
@@ -0,0 +1,66 @@
1
+ """OS-backed single-writer ownership for a saved session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+
11
+
12
+ class SessionBusy(RuntimeError):
13
+ pass
14
+
15
+
16
+ def _record_holder(path: Path) -> None:
17
+ """Note who holds the lease, beside the lock file, so a refusal can name them."""
18
+ command = " ".join(os.path.basename(part) if i == 0 else part
19
+ for i, part in enumerate(sys.argv[:3]))
20
+ try:
21
+ path.write_text(json.dumps({"pid": os.getpid(), "since": time.time(),
22
+ "command": command[:200]}), encoding="utf-8")
23
+ except OSError:
24
+ pass
25
+
26
+
27
+ def describe_holder(path: Path) -> str:
28
+ """`` It is held by process 1234 (rlm-agent worker run), since 14:02.`` or empty."""
29
+ try:
30
+ info = json.loads(path.read_text(encoding="utf-8"))
31
+ pid = int(info["pid"])
32
+ except (OSError, ValueError, KeyError, TypeError):
33
+ return ""
34
+ since = time.strftime("%H:%M", time.localtime(float(info.get("since") or 0)))
35
+ command = str(info.get("command") or "").strip()
36
+ return (f" It is held by process {pid}" + (f" ({command})" if command else "")
37
+ + f", since {since}. Close that window or stop that worker to open it here.")
38
+
39
+
40
+ def acquire(directory: Path, session_id: str):
41
+ if Path(session_id).name != session_id or any(c in session_id for c in "/\\:"):
42
+ raise ValueError("invalid session ID")
43
+ locks = directory / ".leases"
44
+ locks.mkdir(parents=True, exist_ok=True)
45
+ handle = (locks / f"{session_id}.lock").open("a+b")
46
+ try:
47
+ handle.seek(0)
48
+ try:
49
+ if os.name == "nt":
50
+ import msvcrt
51
+
52
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
53
+ else:
54
+ import fcntl
55
+
56
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
57
+ except OSError as exc:
58
+ raise SessionBusy(
59
+ f"Session {session_id} already has an active writer."
60
+ + describe_holder(locks / f"{session_id}.holder")
61
+ ) from exc
62
+ _record_holder(locks / f"{session_id}.holder")
63
+ return handle
64
+ except BaseException:
65
+ handle.close()
66
+ raise
@@ -0,0 +1,118 @@
1
+ """Discriminated record types for the append-only session log."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from time import time
6
+ from typing import Annotated, Literal
7
+ from uuid import uuid4
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field
10
+
11
+ from rlmagent.harness.contracts.transcript import TranscriptEntry
12
+ from rlmagent.harness.contracts.values import JValue
13
+
14
+
15
+ def mint_id() -> str:
16
+ """Generate a hex identifier for a new session record."""
17
+ return uuid4().hex
18
+
19
+
20
+ def now_epoch() -> float:
21
+ """Capture the wall-clock time as a float epoch."""
22
+ return time()
23
+
24
+
25
+ FORMAT = 1 # raise when saved records change shape; older files have no field and count as 1
26
+
27
+
28
+ class EntryBase(BaseModel):
29
+ """Shared header present on every record in the session log."""
30
+
31
+ model_config = ConfigDict(extra="forbid")
32
+
33
+ id: str = Field(default_factory=mint_id)
34
+ parent_id: str | None = None
35
+ timestamp: float = Field(default_factory=now_epoch)
36
+
37
+
38
+ class TranscriptRecord(EntryBase):
39
+ """Wraps a single harness transcript entry for persistence."""
40
+
41
+ type: Literal["message"] = "message"
42
+ message: TranscriptEntry
43
+
44
+
45
+ class ModelSwapRecord(EntryBase):
46
+ """Records a switch to a different model identifier."""
47
+
48
+ type: Literal["model_change"] = "model_change"
49
+ model: str
50
+
51
+
52
+ class ReasoningLevelRecord(EntryBase):
53
+ """Records an adjustment to the reasoning depth setting."""
54
+
55
+ type: Literal["thinking_level_change"] = "thinking_level_change"
56
+ thinking_level: str | None = None
57
+
58
+
59
+ class PruneRecord(EntryBase):
60
+ """A condensed summary that stands in for pruned earlier records on replay."""
61
+
62
+ type: Literal["compaction"] = "compaction"
63
+ summary: str
64
+ replaces_entry_ids: list[str] = Field(default_factory=list)
65
+
66
+
67
+ class ForkSummaryRecord(EntryBase):
68
+ """Captures the synopsis of a diverged conversation branch."""
69
+
70
+ type: Literal["branch_summary"] = "branch_summary"
71
+ summary: str
72
+ branch_root_id: str | None = None
73
+
74
+
75
+ class TagRecord(EntryBase):
76
+ """A user-assigned display name for the session."""
77
+
78
+ type: Literal["label"] = "label"
79
+ label: str
80
+
81
+
82
+ class TipRecord(EntryBase):
83
+ """Points to the current head of the active conversation branch."""
84
+
85
+ type: Literal["leaf"] = "leaf"
86
+ entry_id: str | None = None
87
+
88
+
89
+ class SessionMetaRecord(EntryBase):
90
+ """Top-level metadata stamped when a session is first created."""
91
+
92
+ type: Literal["session_info"] = "session_info"
93
+ format: int = FORMAT
94
+ created_at: float = Field(default_factory=now_epoch)
95
+ cwd: str | None = None
96
+ title: str | None = None
97
+
98
+
99
+ class ExtensionRecord(EntryBase):
100
+ """Opaque payload owned by an application-level plugin."""
101
+
102
+ type: Literal["custom"] = "custom"
103
+ namespace: str
104
+ data: dict[str, JValue] = Field(default_factory=dict)
105
+
106
+
107
+ type SessionRecord = Annotated[
108
+ TranscriptRecord
109
+ | ModelSwapRecord
110
+ | ReasoningLevelRecord
111
+ | PruneRecord
112
+ | ForkSummaryRecord
113
+ | TagRecord
114
+ | TipRecord
115
+ | SessionMetaRecord
116
+ | ExtensionRecord,
117
+ Field(discriminator="type"),
118
+ ]